*/ public function defaults() { return array( 'enabled_post_types' => array( 'page', 'post' ), 'global_auto_detect' => '0', 'selectors' => ".accordion-item\n.elementor-accordion-item\n.wp-block-details", 'question_selectors' => ".accordion-button\n.elementor-tab-title\nsummary", 'answer_selectors' => ".accordion-body\n.elementor-tab-content\n.wp-block-details > *:not(summary)", 'remove_duplicate_questions' => '1', ); } /** * Merged settings (new option with legacy fallback). * * @return array */ public function get_settings() { $saved = get_option( self::OPTION_KEY, array() ); if ( ! is_array( $saved ) || $saved === array() ) { foreach ( array( self::ADEAR_OPTION_KEY, self::LEGACY_OPTION_KEY ) as $legacy_key ) { $legacy = get_option( $legacy_key, array() ); if ( is_array( $legacy ) && $legacy !== array() ) { $saved = $legacy; break; } } } return wp_parse_args( is_array( $saved ) ? $saved : array(), $this->defaults() ); } /** * Read post meta, preferring new keys over legacy PixelCraft keys. * * @param int $post_id Post ID. * @param string $key New meta key. * @param string|array $legacy_keys Old meta keys. * @return mixed */ private function get_post_meta_field( $post_id, $key, $legacy_keys ) { if ( metadata_exists( 'post', $post_id, $key ) ) { return get_post_meta( $post_id, $key, true ); } foreach ( (array) $legacy_keys as $legacy_key ) { if ( metadata_exists( 'post', $post_id, $legacy_key ) ) { return get_post_meta( $post_id, $legacy_key, true ); } } return ''; } /** * Options page under Settings. */ public function add_settings_page() { add_options_page( __( 'Adbear FAQ Schema', 'adbear-faq-schema-structured-data' ), __( 'Adbear FAQ Schema', 'adbear-faq-schema-structured-data' ), 'manage_options', 'adbear-faq-schema-structured-data', array( $this, 'render_settings_page' ) ); } /** * Register option and sanitization. */ public function register_settings() { register_setting( 'adbear_faq_schema_settings_group', self::OPTION_KEY, array( 'type' => 'array', 'sanitize_callback' => array( $this, 'sanitize_settings' ), 'default' => $this->defaults(), 'show_in_rest' => false, ) ); } /** * Sanitize settings on save. * * @param mixed $input Raw input. * @return array */ public function sanitize_settings( $input ) { if ( ! current_user_can( 'manage_options' ) ) { return $this->get_settings(); } $defaults = $this->defaults(); $output = array(); $post_types = isset( $input['enabled_post_types'] ) && is_array( $input['enabled_post_types'] ) ? $input['enabled_post_types'] : array(); $public_post_types = get_post_types( array( 'public' => true ), 'names' ); $output['enabled_post_types'] = array_values( array_intersect( array_map( 'sanitize_key', $post_types ), $public_post_types ) ); $output['global_auto_detect'] = ! empty( $input['global_auto_detect'] ) ? '1' : '0'; $output['remove_duplicate_questions'] = ! empty( $input['remove_duplicate_questions'] ) ? '1' : '0'; $output['selectors'] = isset( $input['selectors'] ) ? sanitize_textarea_field( wp_unslash( $input['selectors'] ) ) : $defaults['selectors']; $output['question_selectors'] = isset( $input['question_selectors'] ) ? sanitize_textarea_field( wp_unslash( $input['question_selectors'] ) ) : $defaults['question_selectors']; $output['answer_selectors'] = isset( $input['answer_selectors'] ) ? sanitize_textarea_field( wp_unslash( $input['answer_selectors'] ) ) : $defaults['answer_selectors']; return $output; } /** * Render global settings. */ public function render_settings_page() { if ( ! current_user_can( 'manage_options' ) ) { return; } $settings = $this->get_settings(); $post_types = get_post_types( array( 'public' => true ), 'objects' ); ?>

get_settings(); foreach ( $settings['enabled_post_types'] as $post_type ) { add_meta_box( 'adbear_faq_schema_box', __( 'Adbear FAQ Schema', 'adbear-faq-schema-structured-data' ), array( $this, 'render_meta_box' ), $post_type, 'normal', 'default' ); } } /** * Meta box markup. * * @param WP_Post $post Post object. */ public function render_meta_box( $post ) { wp_nonce_field( 'adbear_faq_schema_save_meta', 'adbear_faq_schema_nonce' ); $enabled = $this->get_post_meta_field( $post->ID, self::META_ENABLED, array( self::ADEAR_META_ENABLED, self::LEGACY_META_ENABLED ) ); $mode = $this->get_post_meta_field( $post->ID, self::META_MODE, array( self::ADEAR_META_MODE, self::LEGACY_META_MODE ) ); $items = $this->get_post_meta_field( $post->ID, self::META_ITEMS, array( self::ADEAR_META_ITEMS, self::LEGACY_META_ITEMS ) ); if ( $enabled === '' ) { $enabled = 'default'; } if ( $mode === '' ) { $mode = 'manual'; } if ( ! is_array( $items ) ) { $items = array( array( 'question' => '', 'answer' => '' ) ); } ?>

$item ) : ?>

post_type; if ( ! $post_type ) { return; } $settings = $this->get_settings(); if ( ! in_array( $post_type, $settings['enabled_post_types'], true ) ) { return; } $enabled = isset( $_POST['adbear_faq_schema_enabled'] ) ? sanitize_text_field( wp_unslash( $_POST['adbear_faq_schema_enabled'] ) ) : 'default'; if ( ! in_array( $enabled, array( 'default', '1', '0' ), true ) ) { $enabled = 'default'; } update_post_meta( $post_id, self::META_ENABLED, $enabled ); delete_post_meta( $post_id, self::ADEAR_META_ENABLED ); delete_post_meta( $post_id, self::LEGACY_META_ENABLED ); $mode = isset( $_POST['adbear_faq_schema_mode'] ) ? sanitize_text_field( wp_unslash( $_POST['adbear_faq_schema_mode'] ) ) : 'manual'; if ( ! in_array( $mode, array( 'manual', 'auto' ), true ) ) { $mode = 'manual'; } update_post_meta( $post_id, self::META_MODE, $mode ); delete_post_meta( $post_id, self::ADEAR_META_MODE ); delete_post_meta( $post_id, self::LEGACY_META_MODE ); $items = array(); // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Each question and answer is sanitized inside the loop. if ( isset( $_POST['adbear_faq_schema_items'] ) && is_array( $_POST['adbear_faq_schema_items'] ) ) { $raw_items = wp_unslash( $_POST['adbear_faq_schema_items'] ); foreach ( $raw_items as $item ) { if ( ! is_array( $item ) ) { continue; } $question = isset( $item['question'] ) ? sanitize_text_field( $item['question'] ) : ''; $answer = isset( $item['answer'] ) ? wp_kses_post( $item['answer'] ) : ''; if ( $question !== '' && wp_strip_all_tags( $answer ) !== '' ) { $items[] = array( 'question' => $question, 'answer' => $answer, ); } } } // phpcs:enable update_post_meta( $post_id, self::META_ITEMS, $items ); delete_post_meta( $post_id, self::ADEAR_META_ITEMS ); delete_post_meta( $post_id, self::LEGACY_META_ITEMS ); } /** * Admin scripts and styles. * * @param string $hook_suffix Current admin screen. */ public function admin_assets( $hook ) { if ( ! in_array( $hook, array( 'post.php', 'post-new.php', 'settings_page_adbear-faq-schema-structured-data' ), true ) ) { return; } wp_enqueue_script( 'adbear-faq-schema-admin', ADBEAR_FAQ_SCHEMA_URL . 'assets/admin.js', array( 'jquery' ), ADBEAR_FAQ_SCHEMA_VERSION, true ); wp_localize_script( 'adbear-faq-schema-admin', 'adbearFaqSchemaAdmin', array( 'questionLabel' => __( 'Question', 'adbear-faq-schema-structured-data' ), 'answerLabel' => __( 'Answer', 'adbear-faq-schema-structured-data' ), 'removeLabel' => __( 'Remove', 'adbear-faq-schema-structured-data' ), ) ); wp_enqueue_style( 'adbear-faq-schema-admin', ADBEAR_FAQ_SCHEMA_URL . 'assets/admin.css', array(), ADBEAR_FAQ_SCHEMA_VERSION ); } /** * Print JSON-LD in head. */ public function output_schema() { if ( ! is_singular() ) { return; } global $post; if ( ! $post instanceof WP_Post ) { return; } $settings = $this->get_settings(); if ( ! in_array( $post->post_type, $settings['enabled_post_types'], true ) ) { return; } $enabled = $this->get_post_meta_field( $post->ID, self::META_ENABLED, array( self::ADEAR_META_ENABLED, self::LEGACY_META_ENABLED ) ); if ( $enabled === '0' ) { return; } $global_auto = $settings['global_auto_detect'] === '1'; $mode = $this->get_post_meta_field( $post->ID, self::META_MODE, array( self::ADEAR_META_MODE, self::LEGACY_META_MODE ) ); if ( $enabled !== '1' && ! $global_auto ) { return; } $items = array(); if ( $mode === 'auto' || ( $enabled === 'default' && $global_auto ) ) { $items = $this->detect_from_content( $post->post_content, $settings ); } else { $items = $this->get_post_meta_field( $post->ID, self::META_ITEMS, array( self::ADEAR_META_ITEMS, self::LEGACY_META_ITEMS ) ); $items = is_array( $items ) ? $items : array(); } $items = $this->normalize_items( $items, $settings['remove_duplicate_questions'] === '1' ); if ( empty( $items ) ) { return; } $schema = array( '@context' => 'https://schema.org', '@type' => 'FAQPage', 'mainEntity' => array(), ); foreach ( $items as $item ) { $schema['mainEntity'][] = array( '@type' => 'Question', 'name' => $item['question'], 'acceptedAnswer' => array( '@type' => 'Answer', 'text' => $item['answer'], ), ); } $json = wp_json_encode( $schema, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); if ( ! is_string( $json ) ) { return; } echo "\n\n"; wp_print_inline_script_tag( trim( $json ), array( 'type' => 'application/ld+json' ) ); } /** * Parse accordion-like HTML into FAQ items. * * @param string $content Raw post content. * @param array $settings Plugin settings. * @return array> */ private function detect_from_content( $content, $settings ) { if ( trim( (string) $content ) === '' || ! class_exists( 'DOMDocument' ) ) { return array(); } // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core filter required to match front-end rendered post HTML. $html = apply_filters( 'the_content', $content ); if ( trim( (string) $html ) === '' ) { return array(); } libxml_use_internal_errors( true ); $dom = new DOMDocument(); $dom->loadHTML( '
' . $html . '
', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); libxml_clear_errors(); $xpath = new DOMXPath( $dom ); $wrappers = $this->query_by_selector_lines( $xpath, $settings['selectors'] ); $items = array(); foreach ( $wrappers as $wrapper ) { $question_node = $this->first_descendant_by_selector_lines( $xpath, $wrapper, $settings['question_selectors'] ); $answer_node = $this->first_descendant_by_selector_lines( $xpath, $wrapper, $settings['answer_selectors'] ); if ( $question_node && $answer_node ) { $question = trim( wp_strip_all_tags( $dom->saveHTML( $question_node ) ) ); $answer = trim( wp_kses_post( $dom->saveHTML( $answer_node ) ) ); if ( $question !== '' && wp_strip_all_tags( $answer ) !== '' ) { $items[] = array( 'question' => $question, 'answer' => $answer, ); } } } return $items; } /** * @param DOMXPath $xpath XPath instance. * @param string $selector_lines Newline-separated selectors. * @return array */ private function query_by_selector_lines( $xpath, $selector_lines ) { $nodes = array(); $selectors = array_filter( array_map( 'trim', preg_split( '/\r\n|\r|\n/', $selector_lines ) ) ); foreach ( $selectors as $selector ) { $query = $this->selector_to_xpath( $selector ); if ( ! $query ) { continue; } $result = $xpath->query( $query ); if ( $result ) { foreach ( $result as $node ) { $nodes[] = $node; } } } return $nodes; } /** * @param DOMXPath $xpath XPath. * @param DOMNode $context Context node. * @param string $selector_lines Selector lines. * @return DOMNode|null */ private function first_descendant_by_selector_lines( $xpath, $context, $selector_lines ) { $selectors = array_filter( array_map( 'trim', preg_split( '/\r\n|\r|\n/', $selector_lines ) ) ); foreach ( $selectors as $selector ) { $query = $this->selector_to_xpath( $selector, true ); if ( ! $query ) { continue; } $result = $xpath->query( $query, $context ); if ( $result && $result->length > 0 ) { return $result->item( 0 ); } } return null; } /** * Minimal CSS to XPath (safe subset only). * * @param string $selector CSS selector. * @param bool $relative Restrict to descendants of context. * @return string|false */ private function selector_to_xpath( $selector, $relative = false ) { $selector = trim( $selector ); if ( $selector === '' ) { return false; } $prefix = $relative ? './/' : '//'; if ( strpos( $selector, '>' ) !== false ) { $parts = array_map( 'trim', explode( '>', $selector ) ); $xpath = $relative ? '.' : ''; foreach ( $parts as $i => $part ) { $segment = $this->simple_selector_xpath( $part ); if ( ! $segment ) { return false; } $xpath .= ( 0 === $i ? '//' : '/' ) . $segment; } return $xpath; } $segment = $this->simple_selector_xpath( $selector ); return $segment ? $prefix . $segment : false; } /** * Map a single simple selector segment to XPath (class, id, tag only; validated). * * @param string $selector Segment. * @return string|false */ private function simple_selector_xpath( $selector ) { $selector = trim( $selector ); if ( $selector === '' ) { return false; } if ( preg_match( '/^\.([a-zA-Z0-9_-]+)$/', $selector, $m ) ) { $token = $m[1]; return '*[contains(concat(" ", normalize-space(@class), " "), " ' . $token . ' ")]'; } if ( preg_match( '/^#([a-zA-Z0-9_-]+)$/', $selector, $m ) ) { $token = $m[1]; return '*[@id="' . $token . '"]'; } if ( preg_match( '/^([a-zA-Z0-9_-]+)$/', $selector, $m ) ) { return strtolower( $m[1] ); } if ( preg_match( '/^([a-zA-Z0-9_-]+)\.([a-zA-Z0-9_-]+)$/', $selector, $m ) ) { $tag = strtolower( $m[1] ); $class = $m[2]; return $tag . '[contains(concat(" ", normalize-space(@class), " "), " ' . $class . ' ")]'; } return false; } /** * Normalize and optionally deduplicate FAQ rows. * * @param array $items Items. * @param bool $deduplicate Whether to drop duplicate questions. * @return array> */ private function normalize_items( $items, $deduplicate = true ) { $clean = array(); $seen = array(); foreach ( $items as $item ) { $question = isset( $item['question'] ) ? trim( wp_strip_all_tags( (string) $item['question'] ) ) : ''; $answer = isset( $item['answer'] ) ? trim( wp_kses_post( (string) $item['answer'] ) ) : ''; $answer_text = trim( wp_strip_all_tags( $answer ) ); if ( $question === '' || $answer_text === '' ) { continue; } $key = md5( mb_strtolower( $question ) ); if ( $deduplicate && isset( $seen[ $key ] ) ) { continue; } $seen[ $key ] = true; $clean[] = array( 'question' => $question, 'answer' => $answer, ); } return $clean; } }