article-level.php

I’ll build this as a production-style WordPress taxonomy module for your rx-theme, focused on “Article Level” with admin UI, REST support, rewrite rules, SEO helpers, schema hooks, filters, shortcodes, and safe defaults.

Below is a very advanced inc/taxonomies/article-level.php file for your rx-theme.

It registers an Article Level taxonomy, adds admin fields, REST API support, term meta, colors/icons, admin columns, default terms, frontend badges, shortcodes, SEO/meta helpers, schema JSON-LD, admin filters, rewrite support, and helper functions.

WordPress recommends registering custom taxonomies on the init hook, and show_in_rest is required for Block Editor/REST support. Term meta should be registered with register_term_meta() for safer REST/API access.

Create this file:

rx-theme/inc/taxonomies/article-level.php

<?php
/**
 * RX Theme - Article Level Taxonomy
 *
 * File: inc/taxonomies/article-level.php
 *
 * Features:
 * - Registers Article Level taxonomy.
 * - Supports posts and custom post types.
 * - REST API and Gutenberg support.
 * - SEO-friendly rewrite URLs.
 * - Default terms installer.
 * - Term meta: color, icon, order, reading age, score, SEO title, SEO description,
 *   schema name, schema description, featured flag, noindex flag, canonical URL,
 *   hero image ID.
 * - Admin add/edit term fields.
 * - Admin taxonomy columns.
 * - Admin post-list filter dropdown.
 * - Frontend badge helpers.
 * - Shortcodes.
 * - Body/post classes.
 * - Archive SEO meta.
 * - JSON-LD schema output.
 * - Safe sanitization and escaping.
 *
 * @package RX_Theme
 */

defined( 'ABSPATH' ) || exit;

if ( ! defined( 'RX_ARTICLE_LEVEL_TAXONOMY' ) ) {
	define( 'RX_ARTICLE_LEVEL_TAXONOMY', 'article_level' );
}

if ( ! defined( 'RX_ARTICLE_LEVEL_VERSION' ) ) {
	define( 'RX_ARTICLE_LEVEL_VERSION', '1.0.0' );
}

if ( ! class_exists( 'RX_Article_Level_Taxonomy' ) ) :

final class RX_Article_Level_Taxonomy {

	/**
	 * Taxonomy key.
	 *
	 * @var string
	 */
	const TAXONOMY = RX_ARTICLE_LEVEL_TAXONOMY;

	/**
	 * Meta keys.
	 *
	 * @var array
	 */
	private static $meta_keys = array(
		'color'              => '_rx_article_level_color',
		'background_color'   => '_rx_article_level_background_color',
		'icon'               => '_rx_article_level_icon',
		'order'              => '_rx_article_level_order',
		'reading_age'        => '_rx_article_level_reading_age',
		'difficulty_score'   => '_rx_article_level_difficulty_score',
		'estimated_minutes'  => '_rx_article_level_estimated_minutes',
		'seo_title'          => '_rx_article_level_seo_title',
		'seo_description'    => '_rx_article_level_seo_description',
		'schema_name'        => '_rx_article_level_schema_name',
		'schema_description' => '_rx_article_level_schema_description',
		'featured'           => '_rx_article_level_featured',
		'noindex'            => '_rx_article_level_noindex',
		'canonical_url'      => '_rx_article_level_canonical_url',
		'hero_image_id'      => '_rx_article_level_hero_image_id',
	);

	/**
	 * Boot class.
	 *
	 * @return void
	 */
	public static function boot() {
		add_action( 'init', array( __CLASS__, 'register_taxonomy' ), 5 );
		add_action( 'init', array( __CLASS__, 'register_term_meta' ), 6 );
		add_action( 'init', array( __CLASS__, 'maybe_insert_default_terms' ), 20 );

		add_action( self::TAXONOMY . '_add_form_fields', array( __CLASS__, 'add_term_fields' ) );
		add_action( self::TAXONOMY . '_edit_form_fields', array( __CLASS__, 'edit_term_fields' ), 10, 2 );

		add_action( 'created_' . self::TAXONOMY, array( __CLASS__, 'save_term_meta' ) );
		add_action( 'edited_' . self::TAXONOMY, array( __CLASS__, 'save_term_meta' ) );

		add_action( 'admin_enqueue_scripts', array( __CLASS__, 'admin_assets' ) );

		add_filter( 'manage_edit-' . self::TAXONOMY . '_columns', array( __CLASS__, 'admin_columns' ) );
		add_filter( 'manage_' . self::TAXONOMY . '_custom_column', array( __CLASS__, 'admin_column_content' ), 10, 3 );
		add_filter( 'manage_edit-' . self::TAXONOMY . '_sortable_columns', array( __CLASS__, 'sortable_columns' ) );

		add_action( 'restrict_manage_posts', array( __CLASS__, 'admin_filter_dropdown' ) );
		add_filter( 'parse_query', array( __CLASS__, 'admin_filter_query' ) );

		add_filter( 'term_link', array( __CLASS__, 'term_link_filter' ), 10, 3 );
		add_filter( 'get_the_archive_title', array( __CLASS__, 'archive_title' ) );
		add_filter( 'document_title_parts', array( __CLASS__, 'document_title_parts' ) );

		add_action( 'wp_head', array( __CLASS__, 'archive_meta_tags' ), 4 );
		add_action( 'wp_head', array( __CLASS__, 'schema_json_ld' ), 30 );

		add_filter( 'body_class', array( __CLASS__, 'body_classes' ) );
		add_filter( 'post_class', array( __CLASS__, 'post_classes' ), 10, 3 );

		add_shortcode( 'rx_article_level', array( __CLASS__, 'shortcode_article_level' ) );
		add_shortcode( 'rx_article_level_list', array( __CLASS__, 'shortcode_article_level_list' ) );

		add_action( 'save_post', array( __CLASS__, 'maybe_assign_default_level' ), 20, 3 );

		add_action( 'after_switch_theme', array( __CLASS__, 'flush_rewrite_rules_on_theme_switch' ) );
	}

	/**
	 * Register taxonomy.
	 *
	 * @return void
	 */
	public static function register_taxonomy() {
		$object_types = self::get_supported_post_types();

		$labels = array(
			'name'                       => _x( 'Article Levels', 'taxonomy general name', 'rx-theme' ),
			'singular_name'              => _x( 'Article Level', 'taxonomy singular name', 'rx-theme' ),
			'search_items'               => __( 'Search Article Levels', 'rx-theme' ),
			'popular_items'              => __( 'Popular Article Levels', 'rx-theme' ),
			'all_items'                  => __( 'All Article Levels', 'rx-theme' ),
			'parent_item'                => __( 'Parent Article Level', 'rx-theme' ),
			'parent_item_colon'          => __( 'Parent Article Level:', 'rx-theme' ),
			'edit_item'                  => __( 'Edit Article Level', 'rx-theme' ),
			'view_item'                  => __( 'View Article Level', 'rx-theme' ),
			'update_item'                => __( 'Update Article Level', 'rx-theme' ),
			'add_new_item'               => __( 'Add New Article Level', 'rx-theme' ),
			'new_item_name'              => __( 'New Article Level Name', 'rx-theme' ),
			'separate_items_with_commas' => __( 'Separate article levels with commas', 'rx-theme' ),
			'add_or_remove_items'        => __( 'Add or remove article levels', 'rx-theme' ),
			'choose_from_most_used'      => __( 'Choose from the most used article levels', 'rx-theme' ),
			'not_found'                  => __( 'No article levels found.', 'rx-theme' ),
			'no_terms'                   => __( 'No article levels', 'rx-theme' ),
			'filter_by_item'             => __( 'Filter by article level', 'rx-theme' ),
			'items_list_navigation'      => __( 'Article levels list navigation', 'rx-theme' ),
			'items_list'                 => __( 'Article levels list', 'rx-theme' ),
			'most_used'                  => __( 'Most Used', 'rx-theme' ),
			'back_to_items'              => __( '&larr; Back to Article Levels', 'rx-theme' ),
			'menu_name'                  => __( 'Article Levels', 'rx-theme' ),
			'name_admin_bar'             => __( 'Article Level', 'rx-theme' ),
		);

		$args = array(
			'labels'                => $labels,
			'description'           => __( 'Classify articles by reader difficulty, professional depth, or knowledge level.', 'rx-theme' ),
			'public'                => true,
			'publicly_queryable'    => true,
			'hierarchical'          => true,
			'show_ui'               => true,
			'show_in_menu'          => true,
			'show_in_nav_menus'     => true,
			'show_tagcloud'         => false,
			'show_in_quick_edit'    => true,
			'show_admin_column'     => true,
			'show_in_rest'          => true,
			'rest_base'             => 'article-levels',
			'rest_namespace'        => 'wp/v2',
			'query_var'             => self::TAXONOMY,
			'capabilities'          => array(
				'manage_terms' => 'manage_categories',
				'edit_terms'   => 'manage_categories',
				'delete_terms' => 'manage_categories',
				'assign_terms' => 'edit_posts',
			),
			'rewrite'               => array(
				'slug'         => self::get_rewrite_slug(),
				'with_front'   => false,
				'hierarchical' => true,
				'ep_mask'      => EP_NONE,
			),
			'default_term'          => array(
				'name'        => __( 'General Reader', 'rx-theme' ),
				'slug'        => 'general-reader',
				'description' => __( 'Simple, accessible content for general readers.', 'rx-theme' ),
			),
			'sort'                  => true,
			'meta_box_cb'           => 'post_categories_meta_box',
		);

		$args = apply_filters( 'rx_article_level_taxonomy_args', $args, $object_types );

		register_taxonomy( self::TAXONOMY, $object_types, $args );
	}

	/**
	 * Supported post types.
	 *
	 * @return array
	 */
	public static function get_supported_post_types() {
		$post_types = array( 'post' );

		$optional = array(
			'article',
			'medical_article',
			'condition',
			'disease',
			'health_article',
			'rx_article',
			'guide',
			'blog',
		);

		foreach ( $optional as $post_type ) {
			if ( post_type_exists( $post_type ) ) {
				$post_types[] = $post_type;
			}
		}

		return apply_filters( 'rx_article_level_supported_post_types', array_unique( $post_types ) );
	}

	/**
	 * Rewrite slug.
	 *
	 * @return string
	 */
	public static function get_rewrite_slug() {
		return apply_filters( 'rx_article_level_rewrite_slug', 'article-level' );
	}

	/**
	 * Register term meta.
	 *
	 * @return void
	 */
	public static function register_term_meta() {
		$fields = array(
			self::$meta_keys['color'] => array(
				'type'              => 'string',
				'description'       => __( 'Text color for article level badge.', 'rx-theme' ),
				'sanitize_callback' => array( __CLASS__, 'sanitize_color' ),
				'default'           => '#ffffff',
			),
			self::$meta_keys['background_color'] => array(
				'type'              => 'string',
				'description'       => __( 'Background color for article level badge.', 'rx-theme' ),
				'sanitize_callback' => array( __CLASS__, 'sanitize_color' ),
				'default'           => '#0f766e',
			),
			self::$meta_keys['icon'] => array(
				'type'              => 'string',
				'description'       => __( 'Small icon, emoji, or dashicon name.', 'rx-theme' ),
				'sanitize_callback' => 'sanitize_text_field',
				'default'           => '',
			),
			self::$meta_keys['order'] => array(
				'type'              => 'integer',
				'description'       => __( 'Custom display order.', 'rx-theme' ),
				'sanitize_callback' => 'absint',
				'default'           => 0,
			),
			self::$meta_keys['reading_age'] => array(
				'type'              => 'string',
				'description'       => __( 'Suggested reader age or grade level.', 'rx-theme' ),
				'sanitize_callback' => 'sanitize_text_field',
				'default'           => '',
			),
			self::$meta_keys['difficulty_score'] => array(
				'type'              => 'integer',
				'description'       => __( 'Difficulty score from 1 to 10.', 'rx-theme' ),
				'sanitize_callback' => array( __CLASS__, 'sanitize_score' ),
				'default'           => 1,
			),
			self::$meta_keys['estimated_minutes'] => array(
				'type'              => 'integer',
				'description'       => __( 'Estimated reading minutes for this level.', 'rx-theme' ),
				'sanitize_callback' => 'absint',
				'default'           => 0,
			),
			self::$meta_keys['seo_title'] => array(
				'type'              => 'string',
				'description'       => __( 'Custom SEO title for article level archive.', 'rx-theme' ),
				'sanitize_callback' => 'sanitize_text_field',
				'default'           => '',
			),
			self::$meta_keys['seo_description'] => array(
				'type'              => 'string',
				'description'       => __( 'Custom SEO description for article level archive.', 'rx-theme' ),
				'sanitize_callback' => 'sanitize_textarea_field',
				'default'           => '',
			),
			self::$meta_keys['schema_name'] => array(
				'type'              => 'string',
				'description'       => __( 'Schema display name.', 'rx-theme' ),
				'sanitize_callback' => 'sanitize_text_field',
				'default'           => '',
			),
			self::$meta_keys['schema_description'] => array(
				'type'              => 'string',
				'description'       => __( 'Schema display description.', 'rx-theme' ),
				'sanitize_callback' => 'sanitize_textarea_field',
				'default'           => '',
			),
			self::$meta_keys['featured'] => array(
				'type'              => 'boolean',
				'description'       => __( 'Feature this article level.', 'rx-theme' ),
				'sanitize_callback' => array( __CLASS__, 'sanitize_bool' ),
				'default'           => false,
			),
			self::$meta_keys['noindex'] => array(
				'type'              => 'boolean',
				'description'       => __( 'Ask search engines not to index this archive.', 'rx-theme' ),
				'sanitize_callback' => array( __CLASS__, 'sanitize_bool' ),
				'default'           => false,
			),
			self::$meta_keys['canonical_url'] => array(
				'type'              => 'string',
				'description'       => __( 'Custom canonical URL.', 'rx-theme' ),
				'sanitize_callback' => 'esc_url_raw',
				'default'           => '',
			),
			self::$meta_keys['hero_image_id'] => array(
				'type'              => 'integer',
				'description'       => __( 'Hero image attachment ID.', 'rx-theme' ),
				'sanitize_callback' => 'absint',
				'default'           => 0,
			),
		);

		foreach ( $fields as $key => $args ) {
			register_term_meta(
				self::TAXONOMY,
				$key,
				array_merge(
					array(
						'single'       => true,
						'show_in_rest' => true,
						'auth_callback' => function() {
							return current_user_can( 'edit_posts' );
						},
					),
					$args
				)
			);
		}
	}

	/**
	 * Insert default terms once.
	 *
	 * @return void
	 */
	public static function maybe_insert_default_terms() {
		if ( get_option( 'rx_article_level_terms_installed' ) ) {
			return;
		}

		$terms = array(
			array(
				'name'        => 'General Reader',
				'slug'        => 'general-reader',
				'description' => 'Simple, friendly, and easy-to-understand article for all readers.',
				'meta'        => array(
					'color'            => '#ffffff',
					'background_color' => '#0f766e',
					'icon'             => '📘',
					'order'            => 10,
					'reading_age'      => 'General public',
					'difficulty_score' => 1,
				),
			),
			array(
				'name'        => 'Beginner',
				'slug'        => 'beginner',
				'description' => 'Basic article for new learners and non-specialist readers.',
				'meta'        => array(
					'color'            => '#ffffff',
					'background_color' => '#2563eb',
					'icon'             => '🌱',
					'order'            => 20,
					'reading_age'      => 'Beginner reader',
					'difficulty_score' => 2,
				),
			),
			array(
				'name'        => 'Intermediate',
				'slug'        => 'intermediate',
				'description' => 'Moderate-level article with more explanation and technical detail.',
				'meta'        => array(
					'color'            => '#ffffff',
					'background_color' => '#7c3aed',
					'icon'             => '📗',
					'order'            => 30,
					'reading_age'      => 'Intermediate reader',
					'difficulty_score' => 5,
				),
			),
			array(
				'name'        => 'Advanced',
				'slug'        => 'advanced',
				'description' => 'Detailed article for advanced learners, clinicians, and professionals.',
				'meta'        => array(
					'color'            => '#ffffff',
					'background_color' => '#be123c',
					'icon'             => '🧠',
					'order'            => 40,
					'reading_age'      => 'Advanced reader',
					'difficulty_score' => 8,
				),
			),
			array(
				'name'        => 'Professional',
				'slug'        => 'professional',
				'description' => 'Professional-level article with clinical, scientific, or technical depth.',
				'meta'        => array(
					'color'            => '#ffffff',
					'background_color' => '#111827',
					'icon'             => '⚕️',
					'order'            => 50,
					'reading_age'      => 'Professional reader',
					'difficulty_score' => 9,
				),
			),
			array(
				'name'        => 'Research Level',
				'slug'        => 'research-level',
				'description' => 'Research-focused article with evidence, citations, mechanisms, and technical depth.',
				'meta'        => array(
					'color'            => '#ffffff',
					'background_color' => '#92400e',
					'icon'             => '🔬',
					'order'            => 60,
					'reading_age'      => 'Research reader',
					'difficulty_score' => 10,
				),
			),
		);

		foreach ( $terms as $term ) {
			if ( term_exists( $term['slug'], self::TAXONOMY ) ) {
				continue;
			}

			$result = wp_insert_term(
				$term['name'],
				self::TAXONOMY,
				array(
					'slug'        => $term['slug'],
					'description' => $term['description'],
				)
			);

			if ( is_wp_error( $result ) || empty( $result['term_id'] ) ) {
				continue;
			}

			foreach ( $term['meta'] as $meta_key => $value ) {
				if ( isset( self::$meta_keys[ $meta_key ] ) ) {
					update_term_meta( $result['term_id'], self::$meta_keys[ $meta_key ], $value );
				}
			}
		}

		update_option( 'rx_article_level_terms_installed', 1, false );
	}

	/**
	 * Add term fields.
	 *
	 * @return void
	 */
	public static function add_term_fields() {
		wp_nonce_field( 'rx_article_level_save_meta', 'rx_article_level_nonce' );
		?>
		<div class="form-field rx-term-field">
			<label for="rx_article_level_color"><?php esc_html_e( 'Text Color', 'rx-theme' ); ?></label>
			<input type="text" name="rx_article_level_meta[color]" id="rx_article_level_color" value="#ffffff" class="rx-color-field" />
			<p><?php esc_html_e( 'Badge text color. Example: #ffffff.', 'rx-theme' ); ?></p>
		</div>

		<div class="form-field rx-term-field">
			<label for="rx_article_level_background_color"><?php esc_html_e( 'Background Color', 'rx-theme' ); ?></label>
			<input type="text" name="rx_article_level_meta[background_color]" id="rx_article_level_background_color" value="#0f766e" class="rx-color-field" />
			<p><?php esc_html_e( 'Badge background color. Example: #0f766e.', 'rx-theme' ); ?></p>
		</div>

		<div class="form-field rx-term-field">
			<label for="rx_article_level_icon"><?php esc_html_e( 'Icon', 'rx-theme' ); ?></label>
			<input type="text" name="rx_article_level_meta[icon]" id="rx_article_level_icon" value="" />
			<p><?php esc_html_e( 'Use emoji, small text, or icon name. Example: 📘, 🔬, beginner.', 'rx-theme' ); ?></p>
		</div>

		<div class="form-field rx-term-field">
			<label for="rx_article_level_order"><?php esc_html_e( 'Display Order', 'rx-theme' ); ?></label>
			<input type="number" name="rx_article_level_meta[order]" id="rx_article_level_order" value="0" min="0" step="1" />
		</div>

		<div class="form-field rx-term-field">
			<label for="rx_article_level_reading_age"><?php esc_html_e( 'Reading Age / Audience', 'rx-theme' ); ?></label>
			<input type="text" name="rx_article_level_meta[reading_age]" id="rx_article_level_reading_age" value="" />
			<p><?php esc_html_e( 'Example: General public, Medical student, Clinician, Researcher.', 'rx-theme' ); ?></p>
		</div>

		<div class="form-field rx-term-field">
			<label for="rx_article_level_difficulty_score"><?php esc_html_e( 'Difficulty Score', 'rx-theme' ); ?></label>
			<input type="number" name="rx_article_level_meta[difficulty_score]" id="rx_article_level_difficulty_score" value="1" min="1" max="10" step="1" />
			<p><?php esc_html_e( 'Use 1 for easiest and 10 for most advanced.', 'rx-theme' ); ?></p>
		</div>

		<div class="form-field rx-term-field">
			<label for="rx_article_level_estimated_minutes"><?php esc_html_e( 'Estimated Minutes', 'rx-theme' ); ?></label>
			<input type="number" name="rx_article_level_meta[estimated_minutes]" id="rx_article_level_estimated_minutes" value="0" min="0" step="1" />
		</div>

		<div class="form-field rx-term-field">
			<label for="rx_article_level_seo_title"><?php esc_html_e( 'SEO Title', 'rx-theme' ); ?></label>
			<input type="text" name="rx_article_level_meta[seo_title]" id="rx_article_level_seo_title" value="" />
		</div>

		<div class="form-field rx-term-field">
			<label for="rx_article_level_seo_description"><?php esc_html_e( 'SEO Description', 'rx-theme' ); ?></label>
			<textarea name="rx_article_level_meta[seo_description]" id="rx_article_level_seo_description" rows="4"></textarea>
		</div>

		<div class="form-field rx-term-field">
			<label for="rx_article_level_schema_name"><?php esc_html_e( 'Schema Name', 'rx-theme' ); ?></label>
			<input type="text" name="rx_article_level_meta[schema_name]" id="rx_article_level_schema_name" value="" />
		</div>

		<div class="form-field rx-term-field">
			<label for="rx_article_level_schema_description"><?php esc_html_e( 'Schema Description', 'rx-theme' ); ?></label>
			<textarea name="rx_article_level_meta[schema_description]" id="rx_article_level_schema_description" rows="4"></textarea>
		</div>

		<div class="form-field rx-term-field">
			<label>
				<input type="checkbox" name="rx_article_level_meta[featured]" value="1" />
				<?php esc_html_e( 'Featured article level', 'rx-theme' ); ?>
			</label>
		</div>

		<div class="form-field rx-term-field">
			<label>
				<input type="checkbox" name="rx_article_level_meta[noindex]" value="1" />
				<?php esc_html_e( 'Noindex this archive', 'rx-theme' ); ?>
			</label>
		</div>

		<div class="form-field rx-term-field">
			<label for="rx_article_level_canonical_url"><?php esc_html_e( 'Canonical URL', 'rx-theme' ); ?></label>
			<input type="url" name="rx_article_level_meta[canonical_url]" id="rx_article_level_canonical_url" value="" />
		</div>

		<div class="form-field rx-term-field">
			<label for="rx_article_level_hero_image_id"><?php esc_html_e( 'Hero Image ID', 'rx-theme' ); ?></label>
			<input type="number" name="rx_article_level_meta[hero_image_id]" id="rx_article_level_hero_image_id" value="0" min="0" step="1" />
		</div>
		<?php
	}

	/**
	 * Edit term fields.
	 *
	 * @param WP_Term $term Term object.
	 * @return void
	 */
	public static function edit_term_fields( $term ) {
		wp_nonce_field( 'rx_article_level_save_meta', 'rx_article_level_nonce' );

		$meta = self::get_term_meta_array( $term->term_id );
		?>
		<tr class="form-field rx-term-field">
			<th scope="row"><label for="rx_article_level_color"><?php esc_html_e( 'Text Color', 'rx-theme' ); ?></label></th>
			<td>
				<input type="text" name="rx_article_level_meta[color]" id="rx_article_level_color" value="<?php echo esc_attr( $meta['color'] ); ?>" class="rx-color-field" />
				<p class="description"><?php esc_html_e( 'Badge text color.', 'rx-theme' ); ?></p>
			</td>
		</tr>

		<tr class="form-field rx-term-field">
			<th scope="row"><label for="rx_article_level_background_color"><?php esc_html_e( 'Background Color', 'rx-theme' ); ?></label></th>
			<td>
				<input type="text" name="rx_article_level_meta[background_color]" id="rx_article_level_background_color" value="<?php echo esc_attr( $meta['background_color'] ); ?>" class="rx-color-field" />
				<p class="description"><?php esc_html_e( 'Badge background color.', 'rx-theme' ); ?></p>
			</td>
		</tr>

		<tr class="form-field rx-term-field">
			<th scope="row"><label for="rx_article_level_icon"><?php esc_html_e( 'Icon', 'rx-theme' ); ?></label></th>
			<td>
				<input type="text" name="rx_article_level_meta[icon]" id="rx_article_level_icon" value="<?php echo esc_attr( $meta['icon'] ); ?>" />
			</td>
		</tr>

		<tr class="form-field rx-term-field">
			<th scope="row"><label for="rx_article_level_order"><?php esc_html_e( 'Display Order', 'rx-theme' ); ?></label></th>
			<td>
				<input type="number" name="rx_article_level_meta[order]" id="rx_article_level_order" value="<?php echo esc_attr( $meta['order'] ); ?>" min="0" step="1" />
			</td>
		</tr>

		<tr class="form-field rx-term-field">
			<th scope="row"><label for="rx_article_level_reading_age"><?php esc_html_e( 'Reading Age / Audience', 'rx-theme' ); ?></label></th>
			<td>
				<input type="text" name="rx_article_level_meta[reading_age]" id="rx_article_level_reading_age" value="<?php echo esc_attr( $meta['reading_age'] ); ?>" />
			</td>
		</tr>

		<tr class="form-field rx-term-field">
			<th scope="row"><label for="rx_article_level_difficulty_score"><?php esc_html_e( 'Difficulty Score', 'rx-theme' ); ?></label></th>
			<td>
				<input type="number" name="rx_article_level_meta[difficulty_score]" id="rx_article_level_difficulty_score" value="<?php echo esc_attr( $meta['difficulty_score'] ); ?>" min="1" max="10" step="1" />
			</td>
		</tr>

		<tr class="form-field rx-term-field">
			<th scope="row"><label for="rx_article_level_estimated_minutes"><?php esc_html_e( 'Estimated Minutes', 'rx-theme' ); ?></label></th>
			<td>
				<input type="number" name="rx_article_level_meta[estimated_minutes]" id="rx_article_level_estimated_minutes" value="<?php echo esc_attr( $meta['estimated_minutes'] ); ?>" min="0" step="1" />
			</td>
		</tr>

		<tr class="form-field rx-term-field">
			<th scope="row"><label for="rx_article_level_seo_title"><?php esc_html_e( 'SEO Title', 'rx-theme' ); ?></label></th>
			<td>
				<input type="text" class="large-text" name="rx_article_level_meta[seo_title]" id="rx_article_level_seo_title" value="<?php echo esc_attr( $meta['seo_title'] ); ?>" />
			</td>
		</tr>

		<tr class="form-field rx-term-field">
			<th scope="row"><label for="rx_article_level_seo_description"><?php esc_html_e( 'SEO Description', 'rx-theme' ); ?></label></th>
			<td>
				<textarea class="large-text" name="rx_article_level_meta[seo_description]" id="rx_article_level_seo_description" rows="4"><?php echo esc_textarea( $meta['seo_description'] ); ?></textarea>
			</td>
		</tr>

		<tr class="form-field rx-term-field">
			<th scope="row"><label for="rx_article_level_schema_name"><?php esc_html_e( 'Schema Name', 'rx-theme' ); ?></label></th>
			<td>
				<input type="text" class="large-text" name="rx_article_level_meta[schema_name]" id="rx_article_level_schema_name" value="<?php echo esc_attr( $meta['schema_name'] ); ?>" />
			</td>
		</tr>

		<tr class="form-field rx-term-field">
			<th scope="row"><label for="rx_article_level_schema_description"><?php esc_html_e( 'Schema Description', 'rx-theme' ); ?></label></th>
			<td>
				<textarea class="large-text" name="rx_article_level_meta[schema_description]" id="rx_article_level_schema_description" rows="4"><?php echo esc_textarea( $meta['schema_description'] ); ?></textarea>
			</td>
		</tr>

		<tr class="form-field rx-term-field">
			<th scope="row"><?php esc_html_e( 'Featured', 'rx-theme' ); ?></th>
			<td>
				<label>
					<input type="checkbox" name="rx_article_level_meta[featured]" value="1" <?php checked( $meta['featured'] ); ?> />
					<?php esc_html_e( 'Feature this article level', 'rx-theme' ); ?>
				</label>
			</td>
		</tr>

		<tr class="form-field rx-term-field">
			<th scope="row"><?php esc_html_e( 'Noindex', 'rx-theme' ); ?></th>
			<td>
				<label>
					<input type="checkbox" name="rx_article_level_meta[noindex]" value="1" <?php checked( $meta['noindex'] ); ?> />
					<?php esc_html_e( 'Ask search engines not to index this archive', 'rx-theme' ); ?>
				</label>
			</td>
		</tr>

		<tr class="form-field rx-term-field">
			<th scope="row"><label for="rx_article_level_canonical_url"><?php esc_html_e( 'Canonical URL', 'rx-theme' ); ?></label></th>
			<td>
				<input type="url" class="large-text" name="rx_article_level_meta[canonical_url]" id="rx_article_level_canonical_url" value="<?php echo esc_url( $meta['canonical_url'] ); ?>" />
			</td>
		</tr>

		<tr class="form-field rx-term-field">
			<th scope="row"><label for="rx_article_level_hero_image_id"><?php esc_html_e( 'Hero Image ID', 'rx-theme' ); ?></label></th>
			<td>
				<input type="number" name="rx_article_level_meta[hero_image_id]" id="rx_article_level_hero_image_id" value="<?php echo esc_attr( $meta['hero_image_id'] ); ?>" min="0" step="1" />
				<?php
				if ( ! empty( $meta['hero_image_id'] ) ) {
					echo '<div style="margin-top:10px;">';
					echo wp_get_attachment_image( absint( $meta['hero_image_id'] ), 'thumbnail' );
					echo '</div>';
				}
				?>
			</td>
		</tr>
		<?php
	}

	/**
	 * Save term meta.
	 *
	 * @param int $term_id Term ID.
	 * @return void
	 */
	public static function save_term_meta( $term_id ) {
		if ( ! isset( $_POST['rx_article_level_nonce'] ) ) {
			return;
		}

		if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['rx_article_level_nonce'] ) ), 'rx_article_level_save_meta' ) ) {
			return;
		}

		if ( ! current_user_can( 'manage_categories' ) ) {
			return;
		}

		$posted = isset( $_POST['rx_article_level_meta'] ) && is_array( $_POST['rx_article_level_meta'] )
			? wp_unslash( $_POST['rx_article_level_meta'] )
			: array();

		$fields = array(
			'color'              => array( 'callback' => array( __CLASS__, 'sanitize_color' ), 'default' => '#ffffff' ),
			'background_color'   => array( 'callback' => array( __CLASS__, 'sanitize_color' ), 'default' => '#0f766e' ),
			'icon'               => array( 'callback' => 'sanitize_text_field', 'default' => '' ),
			'order'              => array( 'callback' => 'absint', 'default' => 0 ),
			'reading_age'        => array( 'callback' => 'sanitize_text_field', 'default' => '' ),
			'difficulty_score'   => array( 'callback' => array( __CLASS__, 'sanitize_score' ), 'default' => 1 ),
			'estimated_minutes'  => array( 'callback' => 'absint', 'default' => 0 ),
			'seo_title'          => array( 'callback' => 'sanitize_text_field', 'default' => '' ),
			'seo_description'    => array( 'callback' => 'sanitize_textarea_field', 'default' => '' ),
			'schema_name'        => array( 'callback' => 'sanitize_text_field', 'default' => '' ),
			'schema_description' => array( 'callback' => 'sanitize_textarea_field', 'default' => '' ),
			'canonical_url'      => array( 'callback' => 'esc_url_raw', 'default' => '' ),
			'hero_image_id'      => array( 'callback' => 'absint', 'default' => 0 ),
		);

		foreach ( $fields as $field => $config ) {
			$value = isset( $posted[ $field ] ) ? $posted[ $field ] : $config['default'];
			$value = call_user_func( $config['callback'], $value );
			update_term_meta( $term_id, self::$meta_keys[ $field ], $value );
		}

		update_term_meta( $term_id, self::$meta_keys['featured'], ! empty( $posted['featured'] ) ? 1 : 0 );
		update_term_meta( $term_id, self::$meta_keys['noindex'], ! empty( $posted['noindex'] ) ? 1 : 0 );
	}

	/**
	 * Admin assets.
	 *
	 * @param string $hook Hook name.
	 * @return void
	 */
	public static function admin_assets( $hook ) {
		$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;

		if ( ! $screen || self::TAXONOMY !== $screen->taxonomy ) {
			return;
		}

		wp_enqueue_style( 'wp-color-picker' );
		wp_enqueue_script( 'wp-color-picker' );

		$script = "
			jQuery(document).ready(function($){
				$('.rx-color-field').wpColorPicker();
			});
		";

		wp_add_inline_script( 'wp-color-picker', $script );
	}

	/**
	 * Admin columns.
	 *
	 * @param array $columns Columns.
	 * @return array
	 */
	public static function admin_columns( $columns ) {
		$new = array();

		foreach ( $columns as $key => $label ) {
			$new[ $key ] = $label;

			if ( 'name' === $key ) {
				$new['rx_badge']      = __( 'Badge', 'rx-theme' );
				$new['rx_score']      = __( 'Score', 'rx-theme' );
				$new['rx_audience']   = __( 'Audience', 'rx-theme' );
				$new['rx_featured']   = __( 'Featured', 'rx-theme' );
				$new['rx_noindex']    = __( 'Noindex', 'rx-theme' );
				$new['rx_order']      = __( 'Order', 'rx-theme' );
			}
		}

		return $new;
	}

	/**
	 * Admin column content.
	 *
	 * @param string $content Content.
	 * @param string $column_name Column.
	 * @param int    $term_id Term ID.
	 * @return string
	 */
	public static function admin_column_content( $content, $column_name, $term_id ) {
		$meta = self::get_term_meta_array( $term_id );

		switch ( $column_name ) {
			case 'rx_badge':
				return self::get_level_badge_html_by_term_id( $term_id, false );

			case 'rx_score':
				return esc_html( $meta['difficulty_score'] . '/10' );

			case 'rx_audience':
				return esc_html( $meta['reading_age'] );

			case 'rx_featured':
				return $meta['featured'] ? '✅' : '—';

			case 'rx_noindex':
				return $meta['noindex'] ? '🚫' : '—';

			case 'rx_order':
				return esc_html( $meta['order'] );

			default:
				return $content;
		}
	}

	/**
	 * Sortable columns.
	 *
	 * @param array $columns Columns.
	 * @return array
	 */
	public static function sortable_columns( $columns ) {
		$columns['rx_order'] = 'rx_order';
		$columns['rx_score'] = 'rx_score';

		return $columns;
	}

	/**
	 * Admin post filter dropdown.
	 *
	 * @return void
	 */
	public static function admin_filter_dropdown() {
		global $typenow;

		if ( empty( $typenow ) || ! in_array( $typenow, self::get_supported_post_types(), true ) ) {
			return;
		}

		$selected = isset( $_GET[ self::TAXONOMY ] ) ? sanitize_text_field( wp_unslash( $_GET[ self::TAXONOMY ] ) ) : '';

		wp_dropdown_categories(
			array(
				'show_option_all' => __( 'All Article Levels', 'rx-theme' ),
				'taxonomy'        => self::TAXONOMY,
				'name'            => self::TAXONOMY,
				'orderby'         => 'name',
				'selected'        => $selected,
				'hierarchical'    => true,
				'depth'           => 3,
				'show_count'      => true,
				'hide_empty'      => false,
				'value_field'     => 'slug',
			)
		);
	}

	/**
	 * Admin filter query.
	 *
	 * @param WP_Query $query Query.
	 * @return WP_Query
	 */
	public static function admin_filter_query( $query ) {
		global $pagenow;

		if ( ! is_admin() || 'edit.php' !== $pagenow ) {
			return $query;
		}

		if ( empty( $_GET[ self::TAXONOMY ] ) ) {
			return $query;
		}

		$slug = sanitize_text_field( wp_unslash( $_GET[ self::TAXONOMY ] ) );

		if ( '0' === $slug ) {
			return $query;
		}

		$query->query_vars[ self::TAXONOMY ] = $slug;

		return $query;
	}

	/**
	 * Archive title.
	 *
	 * @param string $title Archive title.
	 * @return string
	 */
	public static function archive_title( $title ) {
		if ( is_tax( self::TAXONOMY ) ) {
			$term = get_queried_object();

			if ( $term instanceof WP_Term ) {
				return sprintf(
					/* translators: %s: taxonomy term name */
					__( 'Article Level: %s', 'rx-theme' ),
					single_term_title( '', false )
				);
			}
		}

		return $title;
	}

	/**
	 * Document title parts.
	 *
	 * @param array $parts Title parts.
	 * @return array
	 */
	public static function document_title_parts( $parts ) {
		if ( ! is_tax( self::TAXONOMY ) ) {
			return $parts;
		}

		$term = get_queried_object();

		if ( ! $term instanceof WP_Term ) {
			return $parts;
		}

		$seo_title = get_term_meta( $term->term_id, self::$meta_keys['seo_title'], true );

		if ( $seo_title ) {
			$parts['title'] = $seo_title;
		}

		return $parts;
	}

	/**
	 * Archive meta tags.
	 *
	 * @return void
	 */
	public static function archive_meta_tags() {
		if ( ! is_tax( self::TAXONOMY ) ) {
			return;
		}

		$term = get_queried_object();

		if ( ! $term instanceof WP_Term ) {
			return;
		}

		$description = get_term_meta( $term->term_id, self::$meta_keys['seo_description'], true );
		$noindex     = (bool) get_term_meta( $term->term_id, self::$meta_keys['noindex'], true );
		$canonical   = get_term_meta( $term->term_id, self::$meta_keys['canonical_url'], true );

		if ( ! $description ) {
			$description = term_description( $term, self::TAXONOMY );
			$description = wp_strip_all_tags( $description );
		}

		if ( $description ) {
			echo "\n" . '<meta name="description" content="' . esc_attr( wp_trim_words( $description, 28, '' ) ) . '">' . "\n";
		}

		if ( $noindex ) {
			echo '<meta name="robots" content="noindex,follow">' . "\n";
		}

		if ( $canonical ) {
			echo '<link rel="canonical" href="' . esc_url( $canonical ) . '">' . "\n";
		} else {
			$link = get_term_link( $term );
			if ( ! is_wp_error( $link ) ) {
				echo '<link rel="canonical" href="' . esc_url( $link ) . '">' . "\n";
			}
		}
	}

	/**
	 * Schema JSON-LD.
	 *
	 * @return void
	 */
	public static function schema_json_ld() {
		if ( is_tax( self::TAXONOMY ) ) {
			self::taxonomy_archive_schema();
			return;
		}

		if ( is_singular( self::get_supported_post_types() ) ) {
			self::single_article_level_schema();
		}
	}

	/**
	 * Taxonomy archive schema.
	 *
	 * @return void
	 */
	private static function taxonomy_archive_schema() {
		$term = get_queried_object();

		if ( ! $term instanceof WP_Term ) {
			return;
		}

		$link = get_term_link( $term );

		if ( is_wp_error( $link ) ) {
			return;
		}

		$schema_name = get_term_meta( $term->term_id, self::$meta_keys['schema_name'], true );
		$schema_desc = get_term_meta( $term->term_id, self::$meta_keys['schema_description'], true );

		$data = array(
			'@context'    => 'https://schema.org',
			'@type'       => 'CollectionPage',
			'name'        => $schema_name ? $schema_name : $term->name,
			'description' => $schema_desc ? $schema_desc : wp_strip_all_tags( term_description( $term, self::TAXONOMY ) ),
			'url'         => esc_url_raw( $link ),
			'isPartOf'    => array(
				'@type' => 'WebSite',
				'name'  => get_bloginfo( 'name' ),
				'url'   => home_url( '/' ),
			),
			'about'       => array(
				'@type' => 'DefinedTerm',
				'name'  => $term->name,
				'url'   => esc_url_raw( $link ),
			),
		);

		self::print_json_ld( $data );
	}

	/**
	 * Single post schema addition.
	 *
	 * @return void
	 */
	private static function single_article_level_schema() {
		$post_id = get_queried_object_id();

		if ( ! $post_id ) {
			return;
		}

		$terms = get_the_terms( $post_id, self::TAXONOMY );

		if ( empty( $terms ) || is_wp_error( $terms ) ) {
			return;
		}

		$level_items = array();

		foreach ( $terms as $term ) {
			$link = get_term_link( $term );

			if ( is_wp_error( $link ) ) {
				continue;
			}

			$meta = self::get_term_meta_array( $term->term_id );

			$level_items[] = array(
				'@type'       => 'DefinedTerm',
				'name'        => $term->name,
				'description' => wp_strip_all_tags( $term->description ),
				'url'         => esc_url_raw( $link ),
				'termCode'    => sanitize_title( $term->slug ),
				'inDefinedTermSet' => array(
					'@type' => 'DefinedTermSet',
					'name'  => 'Article Level',
				),
				'additionalProperty' => array(
					array(
						'@type' => 'PropertyValue',
						'name'  => 'difficultyScore',
						'value' => absint( $meta['difficulty_score'] ),
					),
					array(
						'@type' => 'PropertyValue',
						'name'  => 'audience',
						'value' => $meta['reading_age'],
					),
				),
			);
		}

		if ( empty( $level_items ) ) {
			return;
		}

		$data = array(
			'@context'      => 'https://schema.org',
			'@type'         => 'WebPage',
			'@id'           => get_permalink( $post_id ) . '#article-level',
			'name'          => get_the_title( $post_id ),
			'url'           => get_permalink( $post_id ),
			'educationalLevel' => wp_list_pluck( $terms, 'name' ),
			'about'         => $level_items,
		);

		self::print_json_ld( $data );
	}

	/**
	 * Print JSON-LD.
	 *
	 * @param array $data Schema data.
	 * @return void
	 */
	private static function print_json_ld( $data ) {
		$data = apply_filters( 'rx_article_level_schema_data', $data );

		if ( empty( $data ) || ! is_array( $data ) ) {
			return;
		}

		echo "\n" . '<script type="application/ld+json">' . wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) . '</script>' . "\n";
	}

	/**
	 * Add body classes.
	 *
	 * @param array $classes Classes.
	 * @return array
	 */
	public static function body_classes( $classes ) {
		if ( is_tax( self::TAXONOMY ) ) {
			$term = get_queried_object();

			if ( $term instanceof WP_Term ) {
				$classes[] = 'rx-article-level-archive';
				$classes[] = 'rx-article-level-' . sanitize_html_class( $term->slug );
			}
		}

		if ( is_singular( self::get_supported_post_types() ) ) {
			$terms = get_the_terms( get_queried_object_id(), self::TAXONOMY );

			if ( ! empty( $terms ) && ! is_wp_error( $terms ) ) {
				foreach ( $terms as $term ) {
					$classes[] = 'rx-has-article-level-' . sanitize_html_class( $term->slug );
				}
			}
		}

		return array_unique( $classes );
	}

	/**
	 * Add post classes.
	 *
	 * @param array      $classes Classes.
	 * @param string     $class Extra class.
	 * @param int|false  $post_id Post ID.
	 * @return array
	 */
	public static function post_classes( $classes, $class, $post_id ) {
		if ( ! $post_id ) {
			return $classes;
		}

		$terms = get_the_terms( $post_id, self::TAXONOMY );

		if ( empty( $terms ) || is_wp_error( $terms ) ) {
			return $classes;
		}

		$classes[] = 'rx-has-article-level';

		foreach ( $terms as $term ) {
			$classes[] = 'rx-article-level-' . sanitize_html_class( $term->slug );
		}

		return array_unique( $classes );
	}

	/**
	 * Shortcode: [rx_article_level]
	 *
	 * @param array $atts Attributes.
	 * @return string
	 */
	public static function shortcode_article_level( $atts ) {
		$atts = shortcode_atts(
			array(
				'post_id'     => get_the_ID(),
				'link'        => 'yes',
				'show_icon'   => 'yes',
				'show_score'  => 'no',
				'class'       => '',
				'empty'       => '',
			),
			$atts,
			'rx_article_level'
		);

		$post_id = absint( $atts['post_id'] );

		if ( ! $post_id ) {
			return esc_html( $atts['empty'] );
		}

		$html = self::get_post_level_badges(
			$post_id,
			array(
				'link'       => 'yes' === strtolower( $atts['link'] ),
				'show_icon'  => 'yes' === strtolower( $atts['show_icon'] ),
				'show_score' => 'yes' === strtolower( $atts['show_score'] ),
				'class'      => sanitize_html_class( $atts['class'] ),
			)
		);

		return $html ? $html : esc_html( $atts['empty'] );
	}

	/**
	 * Shortcode: [rx_article_level_list]
	 *
	 * @param array $atts Attributes.
	 * @return string
	 */
	public static function shortcode_article_level_list( $atts ) {
		$atts = shortcode_atts(
			array(
				'featured'    => '',
				'hide_empty'  => 'no',
				'show_count'  => 'yes',
				'show_desc'   => 'yes',
				'orderby'     => 'meta_order',
				'limit'       => 0,
				'class'       => '',
			),
			$atts,
			'rx_article_level_list'
		);

		$args = array(
			'taxonomy'   => self::TAXONOMY,
			'hide_empty' => 'yes' === strtolower( $atts['hide_empty'] ),
		);

		$terms = get_terms( $args );

		if ( empty( $terms ) || is_wp_error( $terms ) ) {
			return '';
		}

		$terms = self::sort_terms( $terms );

		if ( '' !== $atts['featured'] ) {
			$want_featured = 'yes' === strtolower( $atts['featured'] );

			$terms = array_filter(
				$terms,
				function( $term ) use ( $want_featured ) {
					return (bool) get_term_meta( $term->term_id, self::$meta_keys['featured'], true ) === $want_featured;
				}
			);
		}

		$limit = absint( $atts['limit'] );

		if ( $limit > 0 ) {
			$terms = array_slice( $terms, 0, $limit );
		}

		$class = 'rx-article-level-list';

		if ( $atts['class'] ) {
			$class .= ' ' . sanitize_html_class( $atts['class'] );
		}

		ob_start();
		?>
		<div class="<?php echo esc_attr( $class ); ?>">
			<?php foreach ( $terms as $term ) : ?>
				<?php
				$link = get_term_link( $term );
				if ( is_wp_error( $link ) ) {
					continue;
				}
				?>
				<div class="rx-article-level-list__item">
					<?php echo self::get_level_badge_html_by_term_id( $term->term_id, true ); ?>
					<?php if ( 'yes' === strtolower( $atts['show_count'] ) ) : ?>
						<span class="rx-article-level-list__count">
							<?php echo esc_html( number_format_i18n( $term->count ) ); ?>
						</span>
					<?php endif; ?>
					<?php if ( 'yes' === strtolower( $atts['show_desc'] ) && $term->description ) : ?>
						<p class="rx-article-level-list__description">
							<?php echo esc_html( wp_trim_words( $term->description, 22 ) ); ?>
						</p>
					<?php endif; ?>
				</div>
			<?php endforeach; ?>
		</div>
		<?php

		return ob_get_clean();
	}

	/**
	 * Maybe assign default level to posts.
	 *
	 * @param int     $post_id Post ID.
	 * @param WP_Post $post Post object.
	 * @param bool    $update Is update.
	 * @return void
	 */
	public static function maybe_assign_default_level( $post_id, $post, $update ) {
		if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
			return;
		}

		if ( empty( $post->post_type ) || ! in_array( $post->post_type, self::get_supported_post_types(), true ) ) {
			return;
		}

		if ( 'auto-draft' === $post->post_status ) {
			return;
		}

		$enabled = apply_filters( 'rx_article_level_auto_assign_default', false, $post_id, $post );

		if ( ! $enabled ) {
			return;
		}

		$terms = wp_get_object_terms( $post_id, self::TAXONOMY, array( 'fields' => 'ids' ) );

		if ( ! empty( $terms ) || is_wp_error( $terms ) ) {
			return;
		}

		$default = get_term_by( 'slug', 'general-reader', self::TAXONOMY );

		if ( $default instanceof WP_Term ) {
			wp_set_object_terms( $post_id, array( $default->term_id ), self::TAXONOMY, false );
		}
	}

	/**
	 * Get term meta array.
	 *
	 * @param int $term_id Term ID.
	 * @return array
	 */
	public static function get_term_meta_array( $term_id ) {
		$data = array(
			'color'              => get_term_meta( $term_id, self::$meta_keys['color'], true ),
			'background_color'   => get_term_meta( $term_id, self::$meta_keys['background_color'], true ),
			'icon'               => get_term_meta( $term_id, self::$meta_keys['icon'], true ),
			'order'              => get_term_meta( $term_id, self::$meta_keys['order'], true ),
			'reading_age'        => get_term_meta( $term_id, self::$meta_keys['reading_age'], true ),
			'difficulty_score'   => get_term_meta( $term_id, self::$meta_keys['difficulty_score'], true ),
			'estimated_minutes'  => get_term_meta( $term_id, self::$meta_keys['estimated_minutes'], true ),
			'seo_title'          => get_term_meta( $term_id, self::$meta_keys['seo_title'], true ),
			'seo_description'    => get_term_meta( $term_id, self::$meta_keys['seo_description'], true ),
			'schema_name'        => get_term_meta( $term_id, self::$meta_keys['schema_name'], true ),
			'schema_description' => get_term_meta( $term_id, self::$meta_keys['schema_description'], true ),
			'featured'           => (bool) get_term_meta( $term_id, self::$meta_keys['featured'], true ),
			'noindex'            => (bool) get_term_meta( $term_id, self::$meta_keys['noindex'], true ),
			'canonical_url'      => get_term_meta( $term_id, self::$meta_keys['canonical_url'], true ),
			'hero_image_id'      => absint( get_term_meta( $term_id, self::$meta_keys['hero_image_id'], true ) ),
		);

		$defaults = array(
			'color'              => '#ffffff',
			'background_color'   => '#0f766e',
			'icon'               => '',
			'order'              => 0,
			'reading_age'        => '',
			'difficulty_score'   => 1,
			'estimated_minutes'  => 0,
			'seo_title'          => '',
			'seo_description'    => '',
			'schema_name'        => '',
			'schema_description' => '',
			'featured'           => false,
			'noindex'            => false,
			'canonical_url'      => '',
			'hero_image_id'      => 0,
		);

		$data = wp_parse_args( $data, $defaults );

		$data['color']            = self::sanitize_color( $data['color'] );
		$data['background_color'] = self::sanitize_color( $data['background_color'] );
		$data['order']            = absint( $data['order'] );
		$data['difficulty_score'] = self::sanitize_score( $data['difficulty_score'] );
		$data['estimated_minutes'] = absint( $data['estimated_minutes'] );

		return apply_filters( 'rx_article_level_term_meta_array', $data, $term_id );
	}

	/**
	 * Get post level badges.
	 *
	 * @param int   $post_id Post ID.
	 * @param array $args Args.
	 * @return string
	 */
	public static function get_post_level_badges( $post_id, $args = array() ) {
		$defaults = array(
			'link'       => true,
			'show_icon'  => true,
			'show_score' => false,
			'class'      => '',
		);

		$args = wp_parse_args( $args, $defaults );

		$terms = get_the_terms( $post_id, self::TAXONOMY );

		if ( empty( $terms ) || is_wp_error( $terms ) ) {
			return '';
		}

		$terms = self::sort_terms( $terms );

		$html = '<span class="rx-article-level-badges">';

		foreach ( $terms as $term ) {
			$html .= self::get_level_badge_html( $term, $args );
		}

		$html .= '</span>';

		return apply_filters( 'rx_article_level_post_badges_html', $html, $post_id, $terms, $args );
	}

	/**
	 * Get badge by term ID.
	 *
	 * @param int  $term_id Term ID.
	 * @param bool $link Whether to link.
	 * @return string
	 */
	public static function get_level_badge_html_by_term_id( $term_id, $link = true ) {
		$term = get_term( $term_id, self::TAXONOMY );

		if ( ! $term instanceof WP_Term || is_wp_error( $term ) ) {
			return '';
		}

		return self::get_level_badge_html(
			$term,
			array(
				'link'       => $link,
				'show_icon'  => true,
				'show_score' => false,
				'class'      => '',
			)
		);
	}

	/**
	 * Get badge HTML.
	 *
	 * @param WP_Term $term Term object.
	 * @param array   $args Args.
	 * @return string
	 */
	public static function get_level_badge_html( $term, $args = array() ) {
		if ( ! $term instanceof WP_Term ) {
			return '';
		}

		$defaults = array(
			'link'       => true,
			'show_icon'  => true,
			'show_score' => false,
			'class'      => '',
		);

		$args = wp_parse_args( $args, $defaults );
		$meta = self::get_term_meta_array( $term->term_id );

		$style = sprintf(
			'color:%s;background-color:%s;',
			esc_attr( $meta['color'] ),
			esc_attr( $meta['background_color'] )
		);

		$class = 'rx-article-level-badge rx-article-level-badge--' . sanitize_html_class( $term->slug );

		if ( $args['class'] ) {
			$class .= ' ' . sanitize_html_class( $args['class'] );
		}

		$label = '';

		if ( $args['show_icon'] && $meta['icon'] ) {
			$label .= '<span class="rx-article-level-badge__icon" aria-hidden="true">' . esc_html( $meta['icon'] ) . '</span> ';
		}

		$label .= '<span class="rx-article-level-badge__name">' . esc_html( $term->name ) . '</span>';

		if ( $args['show_score'] ) {
			$label .= ' <span class="rx-article-level-badge__score">' . esc_html( $meta['difficulty_score'] ) . '/10</span>';
		}

		$title = $meta['reading_age']
			? sprintf( '%s - %s', $term->name, $meta['reading_age'] )
			: $term->name;

		if ( $args['link'] ) {
			$link = get_term_link( $term );

			if ( ! is_wp_error( $link ) ) {
				return sprintf(
					'<a class="%1$s" href="%2$s" style="%3$s" title="%4$s">%5$s</a>',
					esc_attr( $class ),
					esc_url( $link ),
					esc_attr( $style ),
					esc_attr( $title ),
					$label
				);
			}
		}

		return sprintf(
			'<span class="%1$s" style="%2$s" title="%3$s">%4$s</span>',
			esc_attr( $class ),
			esc_attr( $style ),
			esc_attr( $title ),
			$label
		);
	}

	/**
	 * Sort terms by custom order then name.
	 *
	 * @param array $terms Terms.
	 * @return array
	 */
	public static function sort_terms( $terms ) {
		if ( empty( $terms ) || ! is_array( $terms ) ) {
			return array();
		}

		usort(
			$terms,
			function( $a, $b ) {
				$a_order = absint( get_term_meta( $a->term_id, self::$meta_keys['order'], true ) );
				$b_order = absint( get_term_meta( $b->term_id, self::$meta_keys['order'], true ) );

				if ( $a_order === $b_order ) {
					return strcasecmp( $a->name, $b->name );
				}

				return $a_order <=> $b_order;
			}
		);

		return $terms;
	}

	/**
	 * Term link filter.
	 *
	 * @param string  $termlink Term link.
	 * @param WP_Term $term Term.
	 * @param string  $taxonomy Taxonomy.
	 * @return string
	 */
	public static function term_link_filter( $termlink, $term, $taxonomy ) {
		if ( self::TAXONOMY !== $taxonomy ) {
			return $termlink;
		}

		return apply_filters( 'rx_article_level_term_link', $termlink, $term, $taxonomy );
	}

	/**
	 * Sanitize color.
	 *
	 * @param mixed $color Color.
	 * @return string
	 */
	public static function sanitize_color( $color ) {
		$color = sanitize_text_field( (string) $color );

		if ( '' === $color ) {
			return '';
		}

		if ( function_exists( 'sanitize_hex_color' ) ) {
			$hex = sanitize_hex_color( $color );

			if ( $hex ) {
				return $hex;
			}
		}

		if ( preg_match( '/^#[a-fA-F0-9]{3}$/', $color ) || preg_match( '/^#[a-fA-F0-9]{6}$/', $color ) ) {
			return $color;
		}

		return '#0f766e';
	}

	/**
	 * Sanitize difficulty score.
	 *
	 * @param mixed $score Score.
	 * @return int
	 */
	public static function sanitize_score( $score ) {
		$score = absint( $score );

		if ( $score < 1 ) {
			$score = 1;
		}

		if ( $score > 10 ) {
			$score = 10;
		}

		return $score;
	}

	/**
	 * Sanitize bool.
	 *
	 * @param mixed $value Value.
	 * @return bool
	 */
	public static function sanitize_bool( $value ) {
		return (bool) $value;
	}

	/**
	 * Flush rewrites after switching theme.
	 *
	 * @return void
	 */
	public static function flush_rewrite_rules_on_theme_switch() {
		self::register_taxonomy();
		flush_rewrite_rules();
	}
}

RX_Article_Level_Taxonomy::boot();

endif;

/**
 * Helper: Get article level taxonomy key.
 *
 * @return string
 */
function rx_article_level_taxonomy() {
	return RX_Article_Level_Taxonomy::TAXONOMY;
}

/**
 * Helper: Get article level terms for a post.
 *
 * @param int|null $post_id Post ID.
 * @return array
 */
function rx_get_article_levels( $post_id = null ) {
	$post_id = $post_id ? absint( $post_id ) : get_the_ID();

	if ( ! $post_id ) {
		return array();
	}

	$terms = get_the_terms( $post_id, RX_Article_Level_Taxonomy::TAXONOMY );

	if ( empty( $terms ) || is_wp_error( $terms ) ) {
		return array();
	}

	return RX_Article_Level_Taxonomy::sort_terms( $terms );
}

/**
 * Helper: Get article level badges HTML.
 *
 * @param int|null $post_id Post ID.
 * @param array    $args Args.
 * @return string
 */
function rx_get_article_level_badges( $post_id = null, $args = array() ) {
	$post_id = $post_id ? absint( $post_id ) : get_the_ID();

	if ( ! $post_id ) {
		return '';
	}

	return RX_Article_Level_Taxonomy::get_post_level_badges( $post_id, $args );
}

/**
 * Helper: Echo article level badges.
 *
 * @param int|null $post_id Post ID.
 * @param array    $args Args.
 * @return void
 */
function rx_the_article_level_badges( $post_id = null, $args = array() ) {
	echo wp_kses_post( rx_get_article_level_badges( $post_id, $args ) );
}

/**
 * Helper: Get first article level.
 *
 * @param int|null $post_id Post ID.
 * @return WP_Term|null
 */
function rx_get_primary_article_level( $post_id = null ) {
	$levels = rx_get_article_levels( $post_id );

	if ( empty( $levels ) ) {
		return null;
	}

	return reset( $levels );
}

/**
 * Helper: Check whether post has article level.
 *
 * @param string|int $level Term slug, name, or ID.
 * @param int|null   $post_id Post ID.
 * @return bool
 */
function rx_has_article_level( $level, $post_id = null ) {
	$post_id = $post_id ? absint( $post_id ) : get_the_ID();

	if ( ! $post_id || empty( $level ) ) {
		return false;
	}

	return has_term( $level, RX_Article_Level_Taxonomy::TAXONOMY, $post_id );
}

Now include it in your theme functions.php:

/**
 * RX Theme Taxonomies.
 */
require_once get_template_directory() . '/inc/taxonomies/article-level.php';

Optional frontend CSS for style.css:

.rx-article-level-badges {
	display: inline-flex;
	flex-wrap: wrap;
	gap: 6px;
	align-items: center;
}

.rx-article-level-badge {
	display: inline-flex;
	align-items: center;
	gap: 4px;
	padding: 4px 10px;
	border-radius: 999px;
	font-size: 13px;
	font-weight: 700;
	line-height: 1.2;
	text-decoration: none;
	vertical-align: middle;
}

.rx-article-level-badge:hover,
.rx-article-level-badge:focus {
	opacity: 0.88;
	text-decoration: none;
}

.rx-article-level-badge__icon {
	font-size: 14px;
	line-height: 1;
}

.rx-article-level-badge__score {
	font-size: 12px;
	opacity: 0.9;
}

.rx-article-level-list {
	display: grid;
	gap: 14px;
	margin: 20px 0;
}

.rx-article-level-list__item {
	padding: 16px;
	border: 1px solid #e5e7eb;
	border-radius: 12px;
	background: #ffffff;
}

.rx-article-level-list__count {
	margin-left: 8px;
	font-size: 13px;
	font-weight: 700;
	color: #4b5563;
}

.rx-article-level-list__description {
	margin: 10px 0 0;
	color: #4b5563;
}

Usage in template:

<?php rx_the_article_level_badges(); ?>

Shortcodes:

[rx_article_level]

[rx_article_level show_score="yes"]

[rx_article_level_list show_count="yes" show_desc="yes"]

[rx_article_level_list featured="yes"]

After adding this file, go to:

WordPress Dashboard → Settings → Permalinks → Save Changes

This refreshes rewrite rules for the new /article-level/ archive URLs.

Leave a Reply

Your email address will not be published. Required fields are marked *