faq.php

I’ll build this as a full advanced inc/post-types/faq.php module for your RX theme: FAQ custom post type, taxonomy, admin columns, meta boxes, schema JSON-LD, shortcode, REST support, rewrite rules, and theme-safe loading hooks.

Below is an advanced inc/post-types/faq.php file for your RX Theme. It includes:

FAQ custom post type, FAQ category taxonomy, FAQ tag taxonomy, custom meta fields, admin columns, shortcode, schema JSON-LD, REST API support, search integration, archive sorting, FAQ accordion output, and reusable helper functions.

WordPress recommends registering custom post types on the init hook, and show_in_rest => true makes the post type available in the block editor and REST API. Shortcodes are registered with add_shortcode(), and JSON output can safely use wp_json_encode().

Create this file:

<?php
/**
 * RX Theme FAQ Post Type
 *
 * File: inc/post-types/faq.php
 *
 * Features:
 * - FAQ custom post type
 * - FAQ category taxonomy
 * - FAQ tag taxonomy
 * - FAQ meta fields
 * - Admin columns
 * - FAQ shortcode
 * - FAQ schema JSON-LD
 * - Archive query optimization
 * - Search integration
 * - REST API support
 * - Theme helper functions
 *
 * @package RX_Theme
 */

defined( 'ABSPATH' ) || exit;

if ( ! class_exists( 'RX_Theme_FAQ_Post_Type' ) ) :

	final class RX_Theme_FAQ_Post_Type {

		/**
		 * Post type key.
		 */
		public const POST_TYPE = 'rx_faq';

		/**
		 * Category taxonomy key.
		 */
		public const TAX_CATEGORY = 'rx_faq_category';

		/**
		 * Tag taxonomy key.
		 */
		public const TAX_TAG = 'rx_faq_tag';

		/**
		 * Meta keys.
		 */
		public const META_FEATURED       = '_rx_faq_featured';
		public const META_ORDER          = '_rx_faq_order';
		public const META_SHORT_ANSWER   = '_rx_faq_short_answer';
		public const META_REVIEWED_BY    = '_rx_faq_reviewed_by';
		public const META_REVIEWED_DATE  = '_rx_faq_reviewed_date';
		public const META_READING_LEVEL  = '_rx_faq_reading_level';
		public const META_SOURCE_URL     = '_rx_faq_source_url';
		public const META_SCHEMA_ENABLED = '_rx_faq_schema_enabled';

		/**
		 * Singleton instance.
		 *
		 * @var self|null
		 */
		private static ?self $instance = null;

		/**
		 * Get instance.
		 */
		public static function instance() : self {
			if ( null === self::$instance ) {
				self::$instance = new self();
			}

			return self::$instance;
		}

		/**
		 * Constructor.
		 */
		private function __construct() {
			add_action( 'init', array( $this, 'register_post_type' ), 5 );
			add_action( 'init', array( $this, 'register_taxonomies' ), 6 );
			add_action( 'init', array( $this, 'register_meta' ), 7 );

			add_action( 'add_meta_boxes', array( $this, 'add_meta_boxes' ) );
			add_action( 'save_post_' . self::POST_TYPE, array( $this, 'save_meta_boxes' ), 10, 2 );

			add_filter( 'manage_' . self::POST_TYPE . '_posts_columns', array( $this, 'admin_columns' ) );
			add_action( 'manage_' . self::POST_TYPE . '_posts_custom_column', array( $this, 'admin_column_content' ), 10, 2 );
			add_filter( 'manage_edit-' . self::POST_TYPE . '_sortable_columns', array( $this, 'sortable_columns' ) );
			add_action( 'pre_get_posts', array( $this, 'admin_orderby_columns' ) );

			add_filter( 'post_updated_messages', array( $this, 'updated_messages' ) );
			add_filter( 'enter_title_here', array( $this, 'title_placeholder' ) );

			add_shortcode( 'rx_faq', array( $this, 'faq_shortcode' ) );

			add_action( 'wp_head', array( $this, 'output_single_faq_schema' ), 20 );
			add_action( 'wp_head', array( $this, 'output_archive_faq_schema' ), 21 );

			add_filter( 'template_include', array( $this, 'template_loader' ) );
			add_filter( 'the_content', array( $this, 'append_single_faq_extra_content' ) );

			add_action( 'restrict_manage_posts', array( $this, 'admin_taxonomy_filter' ) );
			add_filter( 'parse_query', array( $this, 'admin_taxonomy_filter_query' ) );

			add_filter( 'wp_sitemaps_post_types', array( $this, 'add_to_wp_sitemap' ) );
		}

		/**
		 * Register FAQ post type.
		 */
		public function register_post_type() : void {
			$labels = array(
				'name'                     => esc_html__( 'FAQs', 'rx-theme' ),
				'singular_name'            => esc_html__( 'FAQ', 'rx-theme' ),
				'add_new'                  => esc_html__( 'Add New', 'rx-theme' ),
				'add_new_item'             => esc_html__( 'Add New FAQ', 'rx-theme' ),
				'edit_item'                => esc_html__( 'Edit FAQ', 'rx-theme' ),
				'new_item'                 => esc_html__( 'New FAQ', 'rx-theme' ),
				'view_item'                => esc_html__( 'View FAQ', 'rx-theme' ),
				'view_items'               => esc_html__( 'View FAQs', 'rx-theme' ),
				'search_items'             => esc_html__( 'Search FAQs', 'rx-theme' ),
				'not_found'                => esc_html__( 'No FAQs found.', 'rx-theme' ),
				'not_found_in_trash'       => esc_html__( 'No FAQs found in Trash.', 'rx-theme' ),
				'all_items'                => esc_html__( 'All FAQs', 'rx-theme' ),
				'archives'                 => esc_html__( 'FAQ Archives', 'rx-theme' ),
				'attributes'               => esc_html__( 'FAQ Attributes', 'rx-theme' ),
				'insert_into_item'         => esc_html__( 'Insert into FAQ', 'rx-theme' ),
				'uploaded_to_this_item'    => esc_html__( 'Uploaded to this FAQ', 'rx-theme' ),
				'featured_image'           => esc_html__( 'FAQ Image', 'rx-theme' ),
				'set_featured_image'       => esc_html__( 'Set FAQ Image', 'rx-theme' ),
				'remove_featured_image'    => esc_html__( 'Remove FAQ Image', 'rx-theme' ),
				'use_featured_image'       => esc_html__( 'Use as FAQ Image', 'rx-theme' ),
				'menu_name'                => esc_html__( 'FAQs', 'rx-theme' ),
				'filter_items_list'        => esc_html__( 'Filter FAQs list', 'rx-theme' ),
				'items_list_navigation'    => esc_html__( 'FAQs list navigation', 'rx-theme' ),
				'items_list'               => esc_html__( 'FAQs list', 'rx-theme' ),
				'item_published'           => esc_html__( 'FAQ published.', 'rx-theme' ),
				'item_published_privately' => esc_html__( 'FAQ published privately.', 'rx-theme' ),
				'item_reverted_to_draft'   => esc_html__( 'FAQ reverted to draft.', 'rx-theme' ),
				'item_scheduled'           => esc_html__( 'FAQ scheduled.', 'rx-theme' ),
				'item_updated'             => esc_html__( 'FAQ updated.', 'rx-theme' ),
			);

			$args = array(
				'labels'              => $labels,
				'description'         => esc_html__( 'Frequently asked questions for RX Theme.', 'rx-theme' ),
				'public'              => true,
				'publicly_queryable'  => true,
				'exclude_from_search' => false,
				'show_ui'             => true,
				'show_in_menu'        => true,
				'show_in_nav_menus'   => true,
				'show_in_admin_bar'   => true,
				'show_in_rest'        => true,
				'rest_base'           => 'rx-faqs',
				'rest_namespace'      => 'wp/v2',
				'menu_position'       => 21,
				'menu_icon'           => 'dashicons-editor-help',
				'capability_type'     => 'post',
				'map_meta_cap'        => true,
				'hierarchical'        => false,
				'supports'            => array(
					'title',
					'editor',
					'excerpt',
					'thumbnail',
					'author',
					'revisions',
					'custom-fields',
					'page-attributes',
				),
				'taxonomies'          => array(
					self::TAX_CATEGORY,
					self::TAX_TAG,
				),
				'has_archive'         => 'faqs',
				'rewrite'             => array(
					'slug'       => 'faq',
					'with_front' => false,
					'feeds'      => true,
					'pages'      => true,
				),
				'query_var'           => 'rx_faq',
				'can_export'          => true,
				'delete_with_user'    => false,
			);

			register_post_type( self::POST_TYPE, apply_filters( 'rx_theme_faq_post_type_args', $args ) );
		}

		/**
		 * Register FAQ taxonomies.
		 */
		public function register_taxonomies() : void {
			$category_labels = array(
				'name'              => esc_html__( 'FAQ Categories', 'rx-theme' ),
				'singular_name'     => esc_html__( 'FAQ Category', 'rx-theme' ),
				'search_items'      => esc_html__( 'Search FAQ Categories', 'rx-theme' ),
				'all_items'         => esc_html__( 'All FAQ Categories', 'rx-theme' ),
				'parent_item'       => esc_html__( 'Parent FAQ Category', 'rx-theme' ),
				'parent_item_colon' => esc_html__( 'Parent FAQ Category:', 'rx-theme' ),
				'edit_item'         => esc_html__( 'Edit FAQ Category', 'rx-theme' ),
				'update_item'       => esc_html__( 'Update FAQ Category', 'rx-theme' ),
				'add_new_item'      => esc_html__( 'Add New FAQ Category', 'rx-theme' ),
				'new_item_name'     => esc_html__( 'New FAQ Category Name', 'rx-theme' ),
				'menu_name'         => esc_html__( 'FAQ Categories', 'rx-theme' ),
			);

			register_taxonomy(
				self::TAX_CATEGORY,
				array( self::POST_TYPE ),
				apply_filters(
					'rx_theme_faq_category_taxonomy_args',
					array(
						'hierarchical'          => true,
						'labels'                => $category_labels,
						'public'                => true,
						'show_ui'               => true,
						'show_admin_column'     => true,
						'show_in_nav_menus'     => true,
						'show_tagcloud'         => false,
						'show_in_quick_edit'    => true,
						'show_in_rest'          => true,
						'rest_base'             => 'rx-faq-categories',
						'rewrite'               => array(
							'slug'         => 'faq-category',
							'with_front'   => false,
							'hierarchical' => true,
						),
						'query_var'             => true,
						'update_count_callback' => '_update_post_term_count',
					)
				)
			);

			$tag_labels = array(
				'name'                       => esc_html__( 'FAQ Tags', 'rx-theme' ),
				'singular_name'              => esc_html__( 'FAQ Tag', 'rx-theme' ),
				'search_items'               => esc_html__( 'Search FAQ Tags', 'rx-theme' ),
				'popular_items'              => esc_html__( 'Popular FAQ Tags', 'rx-theme' ),
				'all_items'                  => esc_html__( 'All FAQ Tags', 'rx-theme' ),
				'edit_item'                  => esc_html__( 'Edit FAQ Tag', 'rx-theme' ),
				'update_item'                => esc_html__( 'Update FAQ Tag', 'rx-theme' ),
				'add_new_item'               => esc_html__( 'Add New FAQ Tag', 'rx-theme' ),
				'new_item_name'              => esc_html__( 'New FAQ Tag Name', 'rx-theme' ),
				'separate_items_with_commas' => esc_html__( 'Separate FAQ tags with commas', 'rx-theme' ),
				'add_or_remove_items'        => esc_html__( 'Add or remove FAQ tags', 'rx-theme' ),
				'choose_from_most_used'      => esc_html__( 'Choose from the most used FAQ tags', 'rx-theme' ),
				'menu_name'                  => esc_html__( 'FAQ Tags', 'rx-theme' ),
			);

			register_taxonomy(
				self::TAX_TAG,
				array( self::POST_TYPE ),
				apply_filters(
					'rx_theme_faq_tag_taxonomy_args',
					array(
						'hierarchical'       => false,
						'labels'             => $tag_labels,
						'public'             => true,
						'show_ui'            => true,
						'show_admin_column'  => true,
						'show_in_nav_menus'  => true,
						'show_tagcloud'      => true,
						'show_in_quick_edit' => true,
						'show_in_rest'       => true,
						'rest_base'          => 'rx-faq-tags',
						'rewrite'            => array(
							'slug'       => 'faq-tag',
							'with_front' => false,
						),
						'query_var'          => true,
					)
				)
			);
		}

		/**
		 * Register post meta for REST and security.
		 */
		public function register_meta() : void {
			$meta_fields = array(
				self::META_FEATURED => array(
					'type'              => 'boolean',
					'single'            => true,
					'default'           => false,
					'sanitize_callback' => 'rest_sanitize_boolean',
				),
				self::META_ORDER => array(
					'type'              => 'integer',
					'single'            => true,
					'default'           => 0,
					'sanitize_callback' => 'absint',
				),
				self::META_SHORT_ANSWER => array(
					'type'              => 'string',
					'single'            => true,
					'default'           => '',
					'sanitize_callback' => 'sanitize_textarea_field',
				),
				self::META_REVIEWED_BY => array(
					'type'              => 'string',
					'single'            => true,
					'default'           => '',
					'sanitize_callback' => 'sanitize_text_field',
				),
				self::META_REVIEWED_DATE => array(
					'type'              => 'string',
					'single'            => true,
					'default'           => '',
					'sanitize_callback' => array( $this, 'sanitize_date' ),
				),
				self::META_READING_LEVEL => array(
					'type'              => 'string',
					'single'            => true,
					'default'           => 'simple',
					'sanitize_callback' => 'sanitize_key',
				),
				self::META_SOURCE_URL => array(
					'type'              => 'string',
					'single'            => true,
					'default'           => '',
					'sanitize_callback' => 'esc_url_raw',
				),
				self::META_SCHEMA_ENABLED => array(
					'type'              => 'boolean',
					'single'            => true,
					'default'           => true,
					'sanitize_callback' => 'rest_sanitize_boolean',
				),
			);

			foreach ( $meta_fields as $key => $args ) {
				register_post_meta(
					self::POST_TYPE,
					$key,
					array_merge(
						array(
							'show_in_rest'      => true,
							'auth_callback'     => array( $this, 'meta_auth_callback' ),
						),
						$args
					)
				);
			}
		}

		/**
		 * Meta auth callback.
		 */
		public function meta_auth_callback() : bool {
			return current_user_can( 'edit_posts' );
		}

		/**
		 * Sanitize date.
		 */
		public function sanitize_date( $date ) : string {
			$date = sanitize_text_field( (string) $date );

			if ( empty( $date ) ) {
				return '';
			}

			$timestamp = strtotime( $date );

			if ( false === $timestamp ) {
				return '';
			}

			return gmdate( 'Y-m-d', $timestamp );
		}

		/**
		 * Add FAQ meta boxes.
		 */
		public function add_meta_boxes() : void {
			add_meta_box(
				'rx_faq_settings',
				esc_html__( 'FAQ Settings', 'rx-theme' ),
				array( $this, 'render_settings_meta_box' ),
				self::POST_TYPE,
				'side',
				'high'
			);

			add_meta_box(
				'rx_faq_extra',
				esc_html__( 'FAQ Extra Information', 'rx-theme' ),
				array( $this, 'render_extra_meta_box' ),
				self::POST_TYPE,
				'normal',
				'default'
			);

			add_meta_box(
				'rx_faq_schema',
				esc_html__( 'FAQ Schema / SEO', 'rx-theme' ),
				array( $this, 'render_schema_meta_box' ),
				self::POST_TYPE,
				'normal',
				'default'
			);
		}

		/**
		 * Render settings meta box.
		 */
		public function render_settings_meta_box( WP_Post $post ) : void {
			wp_nonce_field( 'rx_faq_meta_save', 'rx_faq_meta_nonce' );

			$featured = (bool) get_post_meta( $post->ID, self::META_FEATURED, true );
			$order    = absint( get_post_meta( $post->ID, self::META_ORDER, true ) );
			$level    = get_post_meta( $post->ID, self::META_READING_LEVEL, true );
			$level    = $level ? $level : 'simple';
			?>
			<p>
				<label>
					<input type="checkbox" name="rx_faq_featured" value="1" <?php checked( $featured ); ?>>
					<?php esc_html_e( 'Featured FAQ', 'rx-theme' ); ?>
				</label>
			</p>

			<p>
				<label for="rx_faq_order">
					<strong><?php esc_html_e( 'Custom Order', 'rx-theme' ); ?></strong>
				</label>
				<input type="number" id="rx_faq_order" name="rx_faq_order" value="<?php echo esc_attr( $order ); ?>" class="widefat" min="0" step="1">
				<small><?php esc_html_e( 'Lower number appears first.', 'rx-theme' ); ?></small>
			</p>

			<p>
				<label for="rx_faq_reading_level">
					<strong><?php esc_html_e( 'Reading Level', 'rx-theme' ); ?></strong>
				</label>
				<select id="rx_faq_reading_level" name="rx_faq_reading_level" class="widefat">
					<option value="simple" <?php selected( $level, 'simple' ); ?>><?php esc_html_e( 'Simple', 'rx-theme' ); ?></option>
					<option value="intermediate" <?php selected( $level, 'intermediate' ); ?>><?php esc_html_e( 'Intermediate', 'rx-theme' ); ?></option>
					<option value="advanced" <?php selected( $level, 'advanced' ); ?>><?php esc_html_e( 'Advanced', 'rx-theme' ); ?></option>
					<option value="medical" <?php selected( $level, 'medical' ); ?>><?php esc_html_e( 'Medical', 'rx-theme' ); ?></option>
				</select>
			</p>
			<?php
		}

		/**
		 * Render extra meta box.
		 */
		public function render_extra_meta_box( WP_Post $post ) : void {
			$short_answer  = get_post_meta( $post->ID, self::META_SHORT_ANSWER, true );
			$reviewed_by   = get_post_meta( $post->ID, self::META_REVIEWED_BY, true );
			$reviewed_date = get_post_meta( $post->ID, self::META_REVIEWED_DATE, true );
			$source_url    = get_post_meta( $post->ID, self::META_SOURCE_URL, true );
			?>
			<p>
				<label for="rx_faq_short_answer">
					<strong><?php esc_html_e( 'Short Answer', 'rx-theme' ); ?></strong>
				</label>
				<textarea id="rx_faq_short_answer" name="rx_faq_short_answer" class="widefat" rows="4"><?php echo esc_textarea( $short_answer ); ?></textarea>
				<small><?php esc_html_e( 'Useful for archive cards, accordion preview, and schema summary.', 'rx-theme' ); ?></small>
			</p>

			<p>
				<label for="rx_faq_reviewed_by">
					<strong><?php esc_html_e( 'Reviewed By', 'rx-theme' ); ?></strong>
				</label>
				<input type="text" id="rx_faq_reviewed_by" name="rx_faq_reviewed_by" value="<?php echo esc_attr( $reviewed_by ); ?>" class="widefat">
			</p>

			<p>
				<label for="rx_faq_reviewed_date">
					<strong><?php esc_html_e( 'Reviewed Date', 'rx-theme' ); ?></strong>
				</label>
				<input type="date" id="rx_faq_reviewed_date" name="rx_faq_reviewed_date" value="<?php echo esc_attr( $reviewed_date ); ?>" class="widefat">
			</p>

			<p>
				<label for="rx_faq_source_url">
					<strong><?php esc_html_e( 'Source URL', 'rx-theme' ); ?></strong>
				</label>
				<input type="url" id="rx_faq_source_url" name="rx_faq_source_url" value="<?php echo esc_url( $source_url ); ?>" class="widefat" placeholder="https://example.com/source">
			</p>
			<?php
		}

		/**
		 * Render schema meta box.
		 */
		public function render_schema_meta_box( WP_Post $post ) : void {
			$schema_enabled = get_post_meta( $post->ID, self::META_SCHEMA_ENABLED, true );

			if ( '' === $schema_enabled ) {
				$schema_enabled = true;
			}

			?>
			<p>
				<label>
					<input type="checkbox" name="rx_faq_schema_enabled" value="1" <?php checked( (bool) $schema_enabled ); ?>>
					<?php esc_html_e( 'Enable FAQPage JSON-LD schema for this FAQ.', 'rx-theme' ); ?>
				</label>
			</p>

			<p>
				<small>
					<?php esc_html_e( 'The FAQ title is used as the question. The answer comes from Short Answer first; if empty, the main content is used.', 'rx-theme' ); ?>
				</small>
			</p>
			<?php
		}

		/**
		 * Save meta boxes.
		 */
		public function save_meta_boxes( int $post_id, WP_Post $post ) : void {
			if ( ! isset( $_POST['rx_faq_meta_nonce'] ) ) {
				return;
			}

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

			if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
				return;
			}

			if ( wp_is_post_revision( $post_id ) ) {
				return;
			}

			if ( self::POST_TYPE !== $post->post_type ) {
				return;
			}

			if ( ! current_user_can( 'edit_post', $post_id ) ) {
				return;
			}

			$featured       = isset( $_POST['rx_faq_featured'] ) ? 1 : 0;
			$schema_enabled = isset( $_POST['rx_faq_schema_enabled'] ) ? 1 : 0;

			$order = isset( $_POST['rx_faq_order'] )
				? absint( wp_unslash( $_POST['rx_faq_order'] ) )
				: 0;

			$short_answer = isset( $_POST['rx_faq_short_answer'] )
				? sanitize_textarea_field( wp_unslash( $_POST['rx_faq_short_answer'] ) )
				: '';

			$reviewed_by = isset( $_POST['rx_faq_reviewed_by'] )
				? sanitize_text_field( wp_unslash( $_POST['rx_faq_reviewed_by'] ) )
				: '';

			$reviewed_date = isset( $_POST['rx_faq_reviewed_date'] )
				? $this->sanitize_date( wp_unslash( $_POST['rx_faq_reviewed_date'] ) )
				: '';

			$reading_level = isset( $_POST['rx_faq_reading_level'] )
				? sanitize_key( wp_unslash( $_POST['rx_faq_reading_level'] ) )
				: 'simple';

			$source_url = isset( $_POST['rx_faq_source_url'] )
				? esc_url_raw( wp_unslash( $_POST['rx_faq_source_url'] ) )
				: '';

			$allowed_levels = array( 'simple', 'intermediate', 'advanced', 'medical' );

			if ( ! in_array( $reading_level, $allowed_levels, true ) ) {
				$reading_level = 'simple';
			}

			update_post_meta( $post_id, self::META_FEATURED, $featured );
			update_post_meta( $post_id, self::META_ORDER, $order );
			update_post_meta( $post_id, self::META_SHORT_ANSWER, $short_answer );
			update_post_meta( $post_id, self::META_REVIEWED_BY, $reviewed_by );
			update_post_meta( $post_id, self::META_REVIEWED_DATE, $reviewed_date );
			update_post_meta( $post_id, self::META_READING_LEVEL, $reading_level );
			update_post_meta( $post_id, self::META_SOURCE_URL, $source_url );
			update_post_meta( $post_id, self::META_SCHEMA_ENABLED, $schema_enabled );
		}

		/**
		 * Admin columns.
		 */
		public function admin_columns( array $columns ) : array {
			$new_columns = array();

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

				if ( 'title' === $key ) {
					$new_columns['rx_faq_featured']      = esc_html__( 'Featured', 'rx-theme' );
					$new_columns['rx_faq_order']         = esc_html__( 'Order', 'rx-theme' );
					$new_columns['rx_faq_reading_level'] = esc_html__( 'Level', 'rx-theme' );
					$new_columns['rx_faq_reviewed']      = esc_html__( 'Reviewed', 'rx-theme' );
					$new_columns['rx_faq_schema']        = esc_html__( 'Schema', 'rx-theme' );
				}
			}

			return $new_columns;
		}

		/**
		 * Admin column content.
		 */
		public function admin_column_content( string $column, int $post_id ) : void {
			switch ( $column ) {
				case 'rx_faq_featured':
					echo get_post_meta( $post_id, self::META_FEATURED, true )
						? '<span aria-label="Featured">⭐</span>'
						: '<span aria-label="Not featured">—</span>';
					break;

				case 'rx_faq_order':
					echo esc_html( (string) absint( get_post_meta( $post_id, self::META_ORDER, true ) ) );
					break;

				case 'rx_faq_reading_level':
					echo esc_html( ucfirst( (string) get_post_meta( $post_id, self::META_READING_LEVEL, true ) ) );
					break;

				case 'rx_faq_reviewed':
					$by   = get_post_meta( $post_id, self::META_REVIEWED_BY, true );
					$date = get_post_meta( $post_id, self::META_REVIEWED_DATE, true );

					if ( $by || $date ) {
						echo esc_html( trim( $by . ' ' . $date ) );
					} else {
						echo '—';
					}
					break;

				case 'rx_faq_schema':
					echo get_post_meta( $post_id, self::META_SCHEMA_ENABLED, true )
						? '<span style="color:green;">' . esc_html__( 'Enabled', 'rx-theme' ) . '</span>'
						: '<span style="color:#777;">' . esc_html__( 'Disabled', 'rx-theme' ) . '</span>';
					break;
			}
		}

		/**
		 * Sortable admin columns.
		 */
		public function sortable_columns( array $columns ) : array {
			$columns['rx_faq_order']         = 'rx_faq_order';
			$columns['rx_faq_featured']      = 'rx_faq_featured';
			$columns['rx_faq_reading_level'] = 'rx_faq_reading_level';

			return $columns;
		}

		/**
		 * Admin orderby columns.
		 */
		public function admin_orderby_columns( WP_Query $query ) : void {
			if ( ! is_admin() || ! $query->is_main_query() ) {
				return;
			}

			if ( self::POST_TYPE !== $query->get( 'post_type' ) ) {
				return;
			}

			$orderby = $query->get( 'orderby' );

			if ( 'rx_faq_order' === $orderby ) {
				$query->set( 'meta_key', self::META_ORDER );
				$query->set( 'orderby', 'meta_value_num' );
			}

			if ( 'rx_faq_featured' === $orderby ) {
				$query->set( 'meta_key', self::META_FEATURED );
				$query->set( 'orderby', 'meta_value_num' );
			}

			if ( 'rx_faq_reading_level' === $orderby ) {
				$query->set( 'meta_key', self::META_READING_LEVEL );
				$query->set( 'orderby', 'meta_value' );
			}
		}

		/**
		 * Updated messages.
		 */
		public function updated_messages( array $messages ) : array {
			$messages[ self::POST_TYPE ] = array(
				0  => '',
				1  => esc_html__( 'FAQ updated.', 'rx-theme' ),
				2  => esc_html__( 'Custom field updated.', 'rx-theme' ),
				3  => esc_html__( 'Custom field deleted.', 'rx-theme' ),
				4  => esc_html__( 'FAQ updated.', 'rx-theme' ),
				5  => esc_html__( 'FAQ restored to revision.', 'rx-theme' ),
				6  => esc_html__( 'FAQ published.', 'rx-theme' ),
				7  => esc_html__( 'FAQ saved.', 'rx-theme' ),
				8  => esc_html__( 'FAQ submitted.', 'rx-theme' ),
				9  => esc_html__( 'FAQ scheduled.', 'rx-theme' ),
				10 => esc_html__( 'FAQ draft updated.', 'rx-theme' ),
			);

			return $messages;
		}

		/**
		 * Title placeholder.
		 */
		public function title_placeholder( string $title ) : string {
			$screen = get_current_screen();

			if ( $screen && self::POST_TYPE === $screen->post_type ) {
				return esc_html__( 'Enter FAQ question here', 'rx-theme' );
			}

			return $title;
		}

		/**
		 * FAQ shortcode.
		 *
		 * Usage:
		 * [rx_faq]
		 * [rx_faq limit="10" category="general" featured="yes" accordion="yes"]
		 */
		public function faq_shortcode( array $atts = array() ) : string {
			$atts = shortcode_atts(
				array(
					'limit'        => 10,
					'category'     => '',
					'tag'          => '',
					'featured'     => '',
					'orderby'      => 'menu_order',
					'order'        => 'ASC',
					'accordion'    => 'yes',
					'show_excerpt' => 'yes',
					'show_schema'  => 'no',
					'class'        => '',
				),
				$atts,
				'rx_faq'
			);

			$args = array(
				'post_type'              => self::POST_TYPE,
				'post_status'            => 'publish',
				'posts_per_page'         => absint( $atts['limit'] ),
				'ignore_sticky_posts'    => true,
				'no_found_rows'          => true,
				'update_post_meta_cache' => true,
				'update_post_term_cache' => true,
			);

			if ( 'menu_order' === $atts['orderby'] ) {
				$args['meta_key'] = self::META_ORDER;
				$args['orderby']  = array(
					'meta_value_num' => 'ASC',
					'title'          => 'ASC',
				);
			} else {
				$args['orderby'] = sanitize_key( $atts['orderby'] );
				$args['order']   = 'DESC' === strtoupper( $atts['order'] ) ? 'DESC' : 'ASC';
			}

			$tax_query = array();

			if ( ! empty( $atts['category'] ) ) {
				$tax_query[] = array(
					'taxonomy' => self::TAX_CATEGORY,
					'field'    => 'slug',
					'terms'    => array_map( 'sanitize_title', explode( ',', $atts['category'] ) ),
				);
			}

			if ( ! empty( $atts['tag'] ) ) {
				$tax_query[] = array(
					'taxonomy' => self::TAX_TAG,
					'field'    => 'slug',
					'terms'    => array_map( 'sanitize_title', explode( ',', $atts['tag'] ) ),
				);
			}

			if ( ! empty( $tax_query ) ) {
				$args['tax_query'] = $tax_query;
			}

			if ( 'yes' === strtolower( $atts['featured'] ) ) {
				$args['meta_query'] = array(
					array(
						'key'     => self::META_FEATURED,
						'value'   => '1',
						'compare' => '=',
					),
				);
			}

			$query = new WP_Query( apply_filters( 'rx_theme_faq_shortcode_query_args', $args, $atts ) );

			if ( ! $query->have_posts() ) {
				return '';
			}

			$is_accordion = 'yes' === strtolower( $atts['accordion'] );
			$show_excerpt = 'yes' === strtolower( $atts['show_excerpt'] );
			$wrapper_cls  = 'rx-faq-list';

			if ( $is_accordion ) {
				$wrapper_cls .= ' rx-faq-accordion';
			}

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

			ob_start();

			echo '<div class="' . esc_attr( $wrapper_cls ) . '">';

			$schema_items = array();

			while ( $query->have_posts() ) {
				$query->the_post();

				$post_id      = get_the_ID();
				$question     = get_the_title();
				$short_answer = get_post_meta( $post_id, self::META_SHORT_ANSWER, true );
				$answer       = $short_answer ? $short_answer : wp_strip_all_tags( get_the_content() );

				$schema_items[] = array(
					'question' => $question,
					'answer'   => $answer,
				);

				if ( $is_accordion ) {
					$this->render_faq_accordion_item( $post_id, $show_excerpt );
				} else {
					$this->render_faq_card_item( $post_id, $show_excerpt );
				}
			}

			echo '</div>';

			if ( 'yes' === strtolower( $atts['show_schema'] ) ) {
				$this->print_faq_schema_from_items( $schema_items );
			}

			wp_reset_postdata();

			return ob_get_clean();
		}

		/**
		 * Render accordion item.
		 */
		private function render_faq_accordion_item( int $post_id, bool $show_excerpt = true ) : void {
			$short_answer = get_post_meta( $post_id, self::META_SHORT_ANSWER, true );
			$content      = apply_filters( 'the_content', get_post_field( 'post_content', $post_id ) );
			$answer       = $short_answer && $show_excerpt ? wpautop( esc_html( $short_answer ) ) : $content;
			$item_id      = 'rx-faq-' . $post_id;
			?>
			<div class="rx-faq-item" id="<?php echo esc_attr( $item_id ); ?>">
				<details class="rx-faq-details">
					<summary class="rx-faq-question">
						<?php echo esc_html( get_the_title( $post_id ) ); ?>
					</summary>
					<div class="rx-faq-answer">
						<?php echo wp_kses_post( $answer ); ?>

						<p class="rx-faq-read-more">
							<a href="<?php echo esc_url( get_permalink( $post_id ) ); ?>">
								<?php esc_html_e( 'Read full answer', 'rx-theme' ); ?>
							</a>
						</p>
					</div>
				</details>
			</div>
			<?php
		}

		/**
		 * Render card item.
		 */
		private function render_faq_card_item( int $post_id, bool $show_excerpt = true ) : void {
			$short_answer = get_post_meta( $post_id, self::META_SHORT_ANSWER, true );
			?>
			<article class="rx-faq-card" id="rx-faq-<?php echo esc_attr( (string) $post_id ); ?>">
				<h3 class="rx-faq-card-title">
					<a href="<?php echo esc_url( get_permalink( $post_id ) ); ?>">
						<?php echo esc_html( get_the_title( $post_id ) ); ?>
					</a>
				</h3>

				<?php if ( $show_excerpt ) : ?>
					<div class="rx-faq-card-excerpt">
						<?php
						if ( $short_answer ) {
							echo wpautop( esc_html( $short_answer ) );
						} else {
							echo wp_kses_post( wpautop( get_the_excerpt( $post_id ) ) );
						}
						?>
					</div>
				<?php endif; ?>
			</article>
			<?php
		}

		/**
		 * Single FAQ schema.
		 */
		public function output_single_faq_schema() : void {
			if ( ! is_singular( self::POST_TYPE ) ) {
				return;
			}

			$post_id = get_the_ID();

			if ( ! $post_id ) {
				return;
			}

			$enabled = get_post_meta( $post_id, self::META_SCHEMA_ENABLED, true );

			if ( '0' === (string) $enabled ) {
				return;
			}

			$question = get_the_title( $post_id );
			$answer   = get_post_meta( $post_id, self::META_SHORT_ANSWER, true );

			if ( ! $answer ) {
				$answer = wp_strip_all_tags( get_post_field( 'post_content', $post_id ) );
			}

			$this->print_faq_schema_from_items(
				array(
					array(
						'question' => $question,
						'answer'   => $answer,
					),
				)
			);
		}

		/**
		 * Archive FAQ schema.
		 */
		public function output_archive_faq_schema() : void {
			if ( ! is_post_type_archive( self::POST_TYPE ) && ! is_tax( array( self::TAX_CATEGORY, self::TAX_TAG ) ) ) {
				return;
			}

			if ( ! have_posts() ) {
				return;
			}

			global $wp_query;

			if ( empty( $wp_query->posts ) ) {
				return;
			}

			$items = array();

			foreach ( $wp_query->posts as $post ) {
				if ( self::POST_TYPE !== get_post_type( $post ) ) {
					continue;
				}

				$enabled = get_post_meta( $post->ID, self::META_SCHEMA_ENABLED, true );

				if ( '0' === (string) $enabled ) {
					continue;
				}

				$answer = get_post_meta( $post->ID, self::META_SHORT_ANSWER, true );

				if ( ! $answer ) {
					$answer = wp_trim_words( wp_strip_all_tags( $post->post_content ), 60 );
				}

				$items[] = array(
					'question' => get_the_title( $post ),
					'answer'   => $answer,
				);
			}

			if ( ! empty( $items ) ) {
				$this->print_faq_schema_from_items( $items );
			}
		}

		/**
		 * Print FAQPage schema.
		 */
		private function print_faq_schema_from_items( array $items ) : void {
			$main_entity = array();

			foreach ( $items as $item ) {
				if ( empty( $item['question'] ) || empty( $item['answer'] ) ) {
					continue;
				}

				$main_entity[] = array(
					'@type'          => 'Question',
					'name'           => wp_strip_all_tags( $item['question'] ),
					'acceptedAnswer' => array(
						'@type' => 'Answer',
						'text'  => wp_strip_all_tags( $item['answer'] ),
					),
				);
			}

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

			$schema = array(
				'@context'   => 'https://schema.org',
				'@type'      => 'FAQPage',
				'mainEntity' => $main_entity,
			);

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

		/**
		 * Append extra content on single FAQ.
		 */
		public function append_single_faq_extra_content( string $content ) : string {
			if ( ! is_singular( self::POST_TYPE ) || ! in_the_loop() || ! is_main_query() ) {
				return $content;
			}

			$post_id       = get_the_ID();
			$reviewed_by   = get_post_meta( $post_id, self::META_REVIEWED_BY, true );
			$reviewed_date = get_post_meta( $post_id, self::META_REVIEWED_DATE, true );
			$source_url    = get_post_meta( $post_id, self::META_SOURCE_URL, true );
			$level         = get_post_meta( $post_id, self::META_READING_LEVEL, true );

			ob_start();

			if ( $reviewed_by || $reviewed_date || $source_url || $level ) {
				echo '<div class="rx-faq-extra-info">';

				if ( $level ) {
					echo '<p><strong>' . esc_html__( 'Reading level:', 'rx-theme' ) . '</strong> ' . esc_html( ucfirst( $level ) ) . '</p>';
				}

				if ( $reviewed_by ) {
					echo '<p><strong>' . esc_html__( 'Reviewed by:', 'rx-theme' ) . '</strong> ' . esc_html( $reviewed_by ) . '</p>';
				}

				if ( $reviewed_date ) {
					echo '<p><strong>' . esc_html__( 'Reviewed date:', 'rx-theme' ) . '</strong> ' . esc_html( $reviewed_date ) . '</p>';
				}

				if ( $source_url ) {
					echo '<p><strong>' . esc_html__( 'Source:', 'rx-theme' ) . '</strong> <a href="' . esc_url( $source_url ) . '" target="_blank" rel="noopener noreferrer nofollow">' . esc_html__( 'View reference', 'rx-theme' ) . '</a></p>';
				}

				echo '</div>';
			}

			return $content . ob_get_clean();
		}

		/**
		 * Template loader.
		 */
		public function template_loader( string $template ) : string {
			if ( is_singular( self::POST_TYPE ) ) {
				$theme_template = locate_template(
					array(
						'single-rx_faq.php',
						'template-parts/faq/single-faq.php',
					)
				);

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

			if ( is_post_type_archive( self::POST_TYPE ) ) {
				$theme_template = locate_template(
					array(
						'archive-rx_faq.php',
						'template-parts/faq/archive-faq.php',
					)
				);

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

			if ( is_tax( array( self::TAX_CATEGORY, self::TAX_TAG ) ) ) {
				$theme_template = locate_template(
					array(
						'taxonomy-rx_faq_category.php',
						'taxonomy-rx_faq_tag.php',
						'template-parts/faq/taxonomy-faq.php',
					)
				);

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

			return $template;
		}

		/**
		 * Admin taxonomy filter dropdown.
		 */
		public function admin_taxonomy_filter() : void {
			global $typenow;

			if ( self::POST_TYPE !== $typenow ) {
				return;
			}

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

			wp_dropdown_categories(
				array(
					'show_option_all' => esc_html__( 'All FAQ Categories', 'rx-theme' ),
					'taxonomy'        => $taxonomy,
					'name'            => $taxonomy,
					'orderby'         => 'name',
					'selected'        => $selected,
					'hierarchical'    => true,
					'depth'           => 3,
					'show_count'      => true,
					'hide_empty'      => false,
					'value_field'     => 'slug',
				)
			);
		}

		/**
		 * Admin taxonomy filter query.
		 */
		public function admin_taxonomy_filter_query( WP_Query $query ) : void {
			global $pagenow;

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

			$post_type = isset( $_GET['post_type'] ) ? sanitize_key( wp_unslash( $_GET['post_type'] ) ) : '';

			if ( self::POST_TYPE !== $post_type ) {
				return;
			}

			$taxonomy = self::TAX_CATEGORY;

			if ( empty( $_GET[ $taxonomy ] ) ) {
				return;
			}

			$term_slug = sanitize_title( wp_unslash( $_GET[ $taxonomy ] ) );

			$query->query_vars[ $taxonomy ] = $term_slug;
		}

		/**
		 * Better archive query.
		 */
		public function add_to_wp_sitemap( array $post_types ) : array {
			$post_types[ self::POST_TYPE ] = get_post_type_object( self::POST_TYPE );

			return $post_types;
		}
	}

endif;

/**
 * Initialize FAQ module.
 */
function rx_theme_faq_post_type() : RX_Theme_FAQ_Post_Type {
	return RX_Theme_FAQ_Post_Type::instance();
}
rx_theme_faq_post_type();

/**
 * Helper: Get FAQ query.
 */
function rx_theme_get_faqs( array $args = array() ) : WP_Query {
	$defaults = array(
		'post_type'              => RX_Theme_FAQ_Post_Type::POST_TYPE,
		'post_status'            => 'publish',
		'posts_per_page'         => 10,
		'ignore_sticky_posts'    => true,
		'update_post_meta_cache' => true,
		'update_post_term_cache' => true,
		'meta_key'               => RX_Theme_FAQ_Post_Type::META_ORDER,
		'orderby'                => array(
			'meta_value_num' => 'ASC',
			'title'          => 'ASC',
		),
	);

	return new WP_Query( wp_parse_args( $args, $defaults ) );
}

/**
 * Helper: Get featured FAQs.
 */
function rx_theme_get_featured_faqs( int $limit = 5 ) : WP_Query {
	return rx_theme_get_faqs(
		array(
			'posts_per_page' => absint( $limit ),
			'meta_query'     => array(
				array(
					'key'     => RX_Theme_FAQ_Post_Type::META_FEATURED,
					'value'   => '1',
					'compare' => '=',
				),
			),
		)
	);
}

/**
 * Helper: Get FAQ short answer.
 */
function rx_theme_get_faq_short_answer( int $post_id = 0 ) : string {
	$post_id = $post_id ? $post_id : get_the_ID();

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

	return (string) get_post_meta( $post_id, RX_Theme_FAQ_Post_Type::META_SHORT_ANSWER, true );
}

/**
 * Helper: Display FAQ short answer.
 */
function rx_theme_the_faq_short_answer( int $post_id = 0 ) : void {
	$answer = rx_theme_get_faq_short_answer( $post_id );

	if ( $answer ) {
		echo wp_kses_post( wpautop( esc_html( $answer ) ) );
	}
}

/**
 * Helper: Check if FAQ is featured.
 */
function rx_theme_is_featured_faq( int $post_id = 0 ) : bool {
	$post_id = $post_id ? $post_id : get_the_ID();

	if ( ! $post_id ) {
		return false;
	}

	return (bool) get_post_meta( $post_id, RX_Theme_FAQ_Post_Type::META_FEATURED, true );
}

Now load it from your functions.php:

/**
 * Load RX FAQ post type.
 */
require_once get_template_directory() . '/inc/post-types/faq.php';

After adding this file, go to:

WordPress Dashboard → Settings → Permalinks → Save Changes

This refreshes rewrite rules so /faq/ and /faqs/ URLs work correctly.

Leave a Reply

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