post_type ) { return; } wp_enqueue_script( 'forwp-faq-registry-copy', FORWP_FAQ_PLUGIN_URL . 'assets/admin-registry-copy.js', [], FORWP_FAQ_VERSION, true ); } /** * Registry posts come from scan — do not use the native Add New editor. */ public static function redirect_manual_add() { if ( ! Settings::is_setup_complete() ) { return; } $post_type = Settings::get_post_type(); $requested = isset( $_GET['post_type'] ) ? sanitize_key( wp_unslash( $_GET['post_type'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( $post_type !== $requested ) { return; } wp_safe_redirect( Admin_Settings::get_add_guide_url() ); exit; } /** * Admin notice after completing the setup wizard. */ public static function render_setup_success_notice() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( empty( $_GET['forwp_faq_setup_done'] ) ) { return; } printf( '

%s

', esc_html__( '4WP FAQ setup is complete. A content scan has been scheduled.', '4wp-faq' ) ); } /** * Plugin deactivation tasks. */ public static function on_deactivation() { wp_clear_scheduled_hook( self::SCAN_EVENT ); flush_rewrite_rules(); } /** * Register the FAQ wrapper block. */ public static function register_block() { register_block_type( FORWP_FAQ_PLUGIN_DIR . 'block.json', [ 'render_callback' => [ __CLASS__, 'render_block' ], ] ); } /** * Editor config for per-block JSON-LD overrides. */ public static function enqueue_block_editor_assets() { $registry = \WP_Block_Type_Registry::get_instance(); $block = $registry->get_registered( self::BLOCK_NAME ); if ( ! $block || empty( $block->editor_script ) ) { return; } wp_localize_script( $block->editor_script, 'forwpFaqEditor', [ 'globalJsonLdEnabled' => Settings::is_output_json_ld_enabled(), 'registrySetupComplete' => Settings::is_setup_complete(), 'categoriesPath' => '/forwp-faq/v1/editor/categories', ] ); } /** * Render the FAQ wrapper block output. * * @param array $attributes Block attributes. * @param string $content Inner blocks content. * @return string */ public static function render_block( $attributes, $content ) { $class_name = isset( $attributes['className'] ) ? trim( $attributes['className'] ) : ''; $classes = trim( 'wp-block-4wp-faq ' . $class_name ); return sprintf( '
%s
', esc_attr( $classes ), $content ); } /** * Register the aggregated FAQ CPT. */ public static function register_post_type() { $post_type = Settings::get_post_type(); if ( post_type_exists( $post_type ) ) { return; } $labels = [ 'name' => __( '4WP FAQ', '4wp-faq' ), 'singular_name' => __( 'FAQ', '4wp-faq' ), 'menu_name' => __( '4WP FAQ', '4wp-faq' ), 'all_items' => __( 'All FAQs', '4wp-faq' ), 'add_new' => __( 'Add FAQ', '4wp-faq' ), 'add_new_item' => __( 'Add FAQ', '4wp-faq' ), 'edit_item' => __( 'Edit FAQ', '4wp-faq' ), 'new_item' => __( 'New FAQ', '4wp-faq' ), 'view_item' => __( 'View FAQ', '4wp-faq' ), 'search_items' => __( 'Search FAQs', '4wp-faq' ), 'not_found' => __( 'No FAQs found', '4wp-faq' ), 'not_found_in_trash' => __( 'No FAQs found in Trash', '4wp-faq' ), ]; register_post_type( $post_type, [ 'labels' => $labels, 'public' => false, 'show_ui' => true, 'show_in_rest' => true, 'menu_icon' => 'dashicons-editor-help', 'supports' => [ 'title' ], 'taxonomies' => [ Settings::get_taxonomy() ], 'map_meta_cap' => true, 'capabilities' => [ 'create_posts' => 'do_not_allow', ], ] ); } /** * Register the FAQ category taxonomy for the registry CPT. */ public static function register_taxonomy() { $taxonomy = Settings::get_taxonomy(); $post_type = Settings::get_post_type(); if ( taxonomy_exists( $taxonomy ) ) { return; } $labels = [ 'name' => __( 'FAQ Categories', '4wp-faq' ), 'singular_name' => __( 'FAQ Category', '4wp-faq' ), 'search_items' => __( 'Search FAQ Categories', '4wp-faq' ), 'all_items' => __( 'All FAQ Categories', '4wp-faq' ), 'parent_item' => __( 'Parent FAQ Category', '4wp-faq' ), 'parent_item_colon' => __( 'Parent FAQ Category:', '4wp-faq' ), 'edit_item' => __( 'Edit FAQ Category', '4wp-faq' ), 'update_item' => __( 'Update FAQ Category', '4wp-faq' ), 'add_new_item' => __( 'Add New FAQ Category', '4wp-faq' ), 'new_item_name' => __( 'New FAQ Category Name', '4wp-faq' ), 'menu_name' => __( 'FAQ Categories', '4wp-faq' ), ]; register_taxonomy( $taxonomy, [ $post_type ], [ 'labels' => $labels, 'hierarchical' => true, 'public' => false, 'show_ui' => true, 'show_admin_column' => true, 'show_in_rest' => true, 'rewrite' => false, ] ); } /** * Register FAQ meta fields. */ public static function register_post_meta() { $post_type = Settings::get_post_type(); register_post_meta( $post_type, 'answers', [ 'type' => 'array', 'single' => true, 'show_in_rest' => [ 'schema' => [ 'type' => 'array', 'items' => [ 'type' => 'string', ], ], ], 'auth_callback' => '__return_true', ] ); register_post_meta( Settings::get_post_type(), 'answers_html', [ 'type' => 'array', 'single' => true, 'show_in_rest' => [ 'schema' => [ 'type' => 'array', 'items' => [ 'type' => 'string', ], ], ], 'auth_callback' => '__return_true', ] ); register_post_meta( Settings::get_post_type(), 'source_titles', [ 'type' => 'array', 'single' => true, 'show_in_rest' => [ 'schema' => [ 'type' => 'array', 'items' => [ 'type' => 'string', ], ], ], 'auth_callback' => '__return_true', ] ); register_post_meta( Settings::get_post_type(), 'original_question', [ 'type' => 'string', 'single' => true, 'show_in_rest' => true, 'auth_callback' => '__return_true', ] ); register_post_meta( Settings::get_post_type(), 'used_in_posts', [ 'type' => 'array', 'single' => true, 'show_in_rest' => [ 'schema' => [ 'type' => 'array', 'items' => [ 'type' => 'integer', ], ], ], 'auth_callback' => '__return_true', ] ); register_post_meta( Settings::get_post_type(), 'used_in_post_types', [ 'type' => 'array', 'single' => true, 'show_in_rest' => [ 'schema' => [ 'type' => 'array', 'items' => [ 'type' => 'string', ], ], ], 'auth_callback' => '__return_true', ] ); register_post_meta( Settings::get_post_type(), 'count_usage', [ 'type' => 'integer', 'single' => true, 'show_in_rest' => true, 'auth_callback' => '__return_true', ] ); register_post_meta( Settings::get_post_type(), Faq_Terms::PRIMARY_META, [ 'type' => 'integer', 'single' => true, 'show_in_rest' => true, 'auth_callback' => '__return_true', ] ); } /** * Output FAQPage JSON-LD in wp_head for posts containing the FAQ block. */ public static function render_schema() { if ( Yoast::is_active() ) { return; } if ( ! is_singular() ) { return; } global $post; if ( ! $post instanceof \WP_Post ) { return; } $entities = self::collect_schema_entities_for_current_view( $post ); if ( empty( $entities ) ) { return; } $entities = self::prepare_standalone_schema_entities( $entities ); $permalink = get_permalink( $post ); $schema = [ '@context' => 'https://schema.org', '@type' => 'FAQPage', 'mainEntity' => $entities, ]; if ( is_string( $permalink ) && '' !== $permalink ) { $schema['@id'] = $permalink . '#faq'; $schema['url'] = $permalink; } echo ''; } /** * Whether this FAQ wrapper block should contribute to JSON-LD on the front end. * * @param array $attributes Block attributes. * @return bool */ public static function block_outputs_json_ld( $attributes ) { $attributes = is_array( $attributes ) ? $attributes : []; $mode = isset( $attributes['jsonLd'] ) ? sanitize_key( (string) $attributes['jsonLd'] ) : ''; if ( 'enable' === $mode ) { return true; } if ( 'disable' === $mode ) { return false; } return Settings::is_output_json_ld_enabled(); } /** * FAQPage entities for the current view: hub list (what is on the page) or in-place wrappers. * * @param \WP_Post $post Host post. * @return array> */ public static function collect_schema_entities_for_current_view( $post ) { if ( Display_Blocks::document_has_block( Display_Blocks::LIST_BLOCK ) ) { return self::collect_schema_entities_from_list(); } return self::collect_schema_entities_for_post( $post ); } /** * JSON-LD from registry cards actually rendered on this request. * * @return array> */ private static function collect_schema_entities_from_list() { if ( ! Settings::is_output_json_ld_enabled() || ! Settings::is_setup_complete() ) { return []; } $context = Display_Blocks::get_list_query_context(); $posts = Registry_Content::get_visible_posts( $context['includeTermIds'], $context['excludeTermIds'], $context['activeSlug'], $context['previewLimit'], $context['layout'] ); $items = []; foreach ( $posts as $faq_post ) { if ( ! $faq_post instanceof \WP_Post ) { continue; } $answer = Registry_Content::get_answer( (int) $faq_post->ID ); $text = isset( $answer['text'] ) ? trim( (string) $answer['text'] ) : ''; if ( '' === $text && ! empty( $answer['html'] ) ) { $text = trim( wp_strip_all_tags( (string) $answer['html'] ) ); } $question = trim( (string) $faq_post->post_title ); if ( '' === $question || '' === $text ) { continue; } $items[] = [ 'question' => $question, 'answer' => $text, 'anchor' => 'faq-' . (int) $faq_post->ID, ]; } return self::build_schema_entities( $items ); } /** * Collect FAQPage entities respecting global and per-block JSON-LD settings. * * @param \WP_Post $post Post object. * @return array> */ public static function collect_schema_entities_for_post( $post ) { if ( ! $post instanceof \WP_Post || ! has_blocks( $post->post_content ) ) { return []; } $blocks = parse_blocks( $post->post_content ); $entities = []; $seen = []; self::walk_blocks_for_schema_entities( $blocks, $post, $entities, $seen ); return $entities; } /** * @param array> $blocks Parsed blocks. * @param \WP_Post $post Post object. * @param array> $entities Output entities. * @param array $seen Dedupe keys. */ private static function walk_blocks_for_schema_entities( $blocks, $post, &$entities, &$seen ) { foreach ( $blocks as $block ) { $block_name = $block['blockName'] ?? ''; if ( self::BLOCK_NAME === $block_name && self::block_outputs_json_ld( $block['attrs'] ?? [] ) ) { $items = self::extract_items_from_blocks( $block['innerBlocks'] ?? [], $post ); foreach ( self::build_schema_entities( $items ) as $entity ) { $key = self::normalize_question_key( (string) ( $entity['name'] ?? '' ) . '|' . (string) ( $entity['acceptedAnswer']['text'] ?? '' ) ); if ( isset( $seen[ $key ] ) ) { continue; } $seen[ $key ] = true; $entities[] = $entity; } } if ( ! empty( $block['innerBlocks'] ) ) { self::walk_blocks_for_schema_entities( $block['innerBlocks'], $post, $entities, $seen ); } } } /** * Registry and content statistics for the admin settings screen. * * @return array */ public static function get_registry_stats() { $stats = [ 'total_questions' => 0, 'reused_questions' => 0, 'single_use_questions' => 0, 'pages_with_faq' => 0, 'posts_with_faq' => 0, 'other_content_with_faq' => 0, 'faq_blocks' => 0, 'faq_items_in_content' => 0, 'faq_categories' => 0, ]; $content = self::get_content_faq_stats(); $stats['pages_with_faq'] = $content['pages_with_faq']; $stats['posts_with_faq'] = $content['posts_with_faq']; $stats['other_content_with_faq'] = $content['other_content_with_faq']; $stats['faq_blocks'] = $content['faq_blocks']; $stats['faq_items_in_content'] = $content['faq_items_in_content']; if ( ! Settings::is_setup_complete() ) { return $stats; } $post_type = Settings::get_post_type(); $faq_ids = get_posts( [ 'post_type' => $post_type, 'post_status' => 'publish', 'fields' => 'ids', 'posts_per_page' => -1, ] ); $stats['total_questions'] = count( $faq_ids ); $reused = 0; foreach ( $faq_ids as $faq_id ) { $usage = (int) get_post_meta( $faq_id, 'count_usage', true ); if ( $usage > 1 ) { ++$reused; } } $stats['reused_questions'] = $reused; $stats['single_use_questions'] = max( 0, $stats['total_questions'] - $reused ); $taxonomy = Settings::get_taxonomy(); if ( taxonomy_exists( $taxonomy ) ) { $term_count = wp_count_terms( [ 'taxonomy' => $taxonomy, 'hide_empty' => false, ] ); $stats['faq_categories'] = is_wp_error( $term_count ) ? 0 : (int) $term_count; } return $stats; } /** * Dashboard slices: category, usage, source, top reused. * * @return array */ public static function get_dashboard_data() { $stats = self::get_registry_stats(); $last_scan = (int) get_option( 'forwp_faq_last_scan_at', 0 ); $data = [ 'setup_complete' => Settings::is_setup_complete(), 'setup_url' => Setup_Wizard::get_page_url(), 'last_scan_label' => $last_scan ? wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $last_scan ) : '', 'registry_url' => '', 'categories_url' => '', 'stats' => $stats, 'by_category' => [], 'by_usage' => [], 'by_source' => [ [ 'label' => __( 'Pages', '4wp-faq' ), 'count' => (int) $stats['pages_with_faq'], ], [ 'label' => __( 'Posts', '4wp-faq' ), 'count' => (int) $stats['posts_with_faq'], ], [ 'label' => __( 'Other types', '4wp-faq' ), 'count' => (int) $stats['other_content_with_faq'], ], ], 'top_reused' => [], 'uncategorized' => [], ]; if ( ! Settings::is_setup_complete() ) { return $data; } $post_type = Settings::get_post_type(); $taxonomy = Settings::get_taxonomy(); $data['registry_url'] = admin_url( 'edit.php?post_type=' . rawurlencode( $post_type ) ); $data['categories_url'] = admin_url( 'edit-tags.php?taxonomy=' . rawurlencode( $taxonomy ) . '&post_type=' . rawurlencode( $post_type ) ); $faq_ids = get_posts( [ 'post_type' => $post_type, 'post_status' => 'publish', 'fields' => 'ids', 'posts_per_page' => -1, ] ); $unused = 0; $reused = []; $no_term = []; foreach ( $faq_ids as $faq_id ) { $faq_id = (int) $faq_id; $usage = (int) get_post_meta( $faq_id, 'count_usage', true ); if ( $usage < 1 ) { ++$unused; } elseif ( $usage > 1 ) { $reused[] = [ 'id' => $faq_id, 'title' => get_the_title( $faq_id ), 'count' => $usage, 'edit_url' => get_edit_post_link( $faq_id, 'raw' ), ]; } if ( 0 === Faq_Terms::get_primary_term_id( $faq_id ) ) { $no_term[] = [ 'id' => $faq_id, 'title' => get_the_title( $faq_id ), 'edit_url' => get_edit_post_link( $faq_id, 'raw' ), ]; } } usort( $reused, static function ( $a, $b ) { return (int) $b['count'] <=> (int) $a['count']; } ); $data['top_reused'] = array_slice( $reused, 0, 8 ); $data['uncategorized'] = array_slice( $no_term, 0, 8 ); $data['stats']['uncategorized'] = count( $no_term ); $data['stats']['unused_questions'] = $unused; $data['by_usage'] = [ [ 'label' => __( 'Reused', '4wp-faq' ), 'count' => (int) $stats['reused_questions'], ], [ 'label' => __( 'Used once', '4wp-faq' ), 'count' => (int) $stats['single_use_questions'], ], [ 'label' => __( 'Unused', '4wp-faq' ), 'count' => $unused, ], ]; if ( taxonomy_exists( $taxonomy ) ) { $walk = static function ( $nodes ) use ( &$walk, $post_type, $taxonomy ) { $out = []; if ( ! is_array( $nodes ) ) { return $out; } foreach ( $nodes as $node ) { $term = $node['term'] ?? null; if ( ! $term instanceof \WP_Term ) { continue; } $children = []; if ( ! empty( $node['children'] ) && is_array( $node['children'] ) ) { $children = $walk( $node['children'] ); } $out[] = [ 'id' => (int) $term->term_id, 'name' => $term->name, 'count' => (int) ( $node['inclusive_count'] ?? $term->count ), 'url' => admin_url( 'edit.php?post_type=' . rawurlencode( $post_type ) . '&' . rawurlencode( $taxonomy ) . '=' . rawurlencode( $term->slug ) ), 'children' => $children, ]; } return $out; }; $data['by_category'] = $walk( Registry_Content::get_category_tree_with_counts() ); } return $data; } /** * Live counts from published content (independent of registry scan). * * @return array */ private static function get_content_faq_stats() { $page_ids = []; $post_ids = []; $other_ids = []; $block_count = 0; $item_count = 0; $post_ids_query = get_posts( [ 'post_type' => 'any', 'post_status' => 'publish', 'fields' => 'ids', 'posts_per_page' => -1, ] ); foreach ( $post_ids_query as $post_id ) { $post = get_post( $post_id ); if ( ! $post instanceof \WP_Post || ! has_block( self::BLOCK_NAME, $post ) ) { continue; } if ( 'page' === $post->post_type ) { $page_ids[ $post_id ] = true; } elseif ( 'post' === $post->post_type ) { $post_ids[ $post_id ] = true; } else { $other_ids[ $post_id ] = true; } if ( has_blocks( $post->post_content ) ) { $parsed = parse_blocks( $post->post_content ); self::count_faq_blocks_in_tree( $parsed, $block_count, $item_count, $post ); } } return [ 'pages_with_faq' => count( $page_ids ), 'posts_with_faq' => count( $post_ids ), 'other_content_with_faq' => count( $other_ids ), 'faq_blocks' => $block_count, 'faq_items_in_content' => $item_count, ]; } /** * @param array> $tree Parsed blocks. * @param int $block_count FAQ wrapper count (by ref). * @param int $item_count FAQ item count (by ref). * @param \WP_Post $post Post object. */ private static function count_faq_blocks_in_tree( $tree, &$block_count, &$item_count, $post ) { foreach ( $tree as $block ) { $block_name = $block['blockName'] ?? ''; if ( self::BLOCK_NAME === $block_name ) { ++$block_count; $item_count += count( self::extract_items_from_blocks( $block['innerBlocks'] ?? [], $post ) ); } if ( ! empty( $block['innerBlocks'] ) ) { self::count_faq_blocks_in_tree( $block['innerBlocks'], $block_count, $item_count, $post ); } } } /** * Schedule a full scan when content changes. * * @param int $post_id Post ID. * @param \WP_Post|null $post Post object. * @param bool $update Whether this is an update. */ public static function handle_post_save( $post_id, $post, $update ) { if ( ! $post instanceof \WP_Post ) { return; } if ( $post->post_type === Settings::get_post_type() ) { return; } if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) { return; } self::schedule_scan(); } /** * Schedule a scan if one is not already queued. */ public static function schedule_scan() { if ( ! wp_next_scheduled( self::SCAN_EVENT ) ) { wp_schedule_single_event( time() + 60, self::SCAN_EVENT ); } } /** * Scan all posts to aggregate FAQ items into the CPT. */ public static function scan_all_posts() { $post_ids = get_posts( [ 'post_type' => 'any', 'post_status' => [ 'publish', 'draft', 'pending', 'future', 'private' ], 'fields' => 'ids', 'posts_per_page' => -1, ] ); $items = []; foreach ( $post_ids as $post_id ) { $post = get_post( $post_id ); if ( ! $post instanceof \WP_Post ) { continue; } if ( ! has_block( self::BLOCK_NAME, $post ) ) { continue; } $items = array_merge( $items, self::extract_faq_items_from_content( $post->post_content, $post ) ); } /** * Extra FAQ items to merge into the registry scan (JSON catalogs, virtual sources). * * @param array> $items Items extracted from posts. */ $items = apply_filters( 'forwp_faq_scan_items', $items ); $aggregated = self::aggregate_items( $items ); self::sync_faq_posts( $aggregated ); update_option( 'forwp_faq_last_scan_at', time() ); } /** * Handle manual scan requests. */ public static function handle_manual_scan() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Access denied.', '4wp-faq' ) ); } check_admin_referer( 'forwp_faq_scan' ); self::scan_all_posts(); $redirect = ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- checked via check_admin_referer above. if ( ! empty( $_POST['forwp_faq_redirect'] ) ) { $redirect_raw = sanitize_text_field( wp_unslash( $_POST['forwp_faq_redirect'] ) ); $redirect = wp_validate_redirect( $redirect_raw, '' ); } if ( ! $redirect ) { $redirect = wp_get_referer(); } if ( ! $redirect ) { $redirect = Admin_Settings::get_page_url(); } $redirect = add_query_arg( 'forwp_faq_scan_done', '1', $redirect ); wp_safe_redirect( $redirect ); exit; } /** * Extract FAQ items from a post's content. * * @param string $content Post content. * @param \WP_Post $post Post object. * @return array> */ private static function extract_faq_items_from_content( $content, $post ) { if ( ! has_blocks( $content ) ) { return []; } $blocks = parse_blocks( $content ); return self::collect_faq_items_from_blocks( $blocks, $post ); } /** * Traverse blocks to find FAQ wrappers and items. * * @param array $blocks Parsed blocks. * @param \WP_Post $post Post object. * @return array> */ private static function collect_faq_items_from_blocks( $blocks, $post ) { $items = []; foreach ( $blocks as $block ) { $block_name = $block['blockName'] ?? ''; if ( self::BLOCK_NAME === $block_name ) { $category_term_ids = Category_Resolver::resolve_from_block_attrs( $block['attrs'] ?? [], (int) $post->ID ); $items = array_merge( $items, self::extract_items_from_blocks( $block['innerBlocks'] ?? [], $post, $category_term_ids ) ); } if ( ! empty( $block['innerBlocks'] ) ) { $items = array_merge( $items, self::collect_faq_items_from_blocks( $block['innerBlocks'], $post ) ); } } return $items; } /** * Extract FAQ items from accordion-style blocks. * * @param array $blocks Parsed blocks. * @param \WP_Post $post Post object. * @param int[] $category_term_ids Category term IDs from the FAQ wrapper block. * @return array> */ private static function extract_items_from_blocks( $blocks, $post, $category_term_ids = [] ) { $items = []; foreach ( $blocks as $block ) { $block_name = $block['blockName'] ?? ''; if ( self::is_faq_item_block( $block_name ) ) { $item = self::build_item_from_block( $block, $post, $category_term_ids ); if ( $item ) { $items[] = $item; } } if ( ! empty( $block['innerBlocks'] ) ) { $items = array_merge( $items, self::extract_items_from_blocks( $block['innerBlocks'], $post, $category_term_ids ) ); } } return $items; } /** * Determine if a block can be treated as a FAQ item. * * @param string $block_name Block name. * @return bool */ private static function is_faq_item_block( $block_name ) { if ( ! is_string( $block_name ) || '' === $block_name ) { return false; } if ( 'core/accordion-item' === $block_name || false !== strpos( $block_name, 'accordion-item' ) ) { return true; } return 'core/details' === $block_name || false !== strpos( $block_name, '/details' ); } /** * Build a FAQ item from a block. * * @param array $block Block data. * @param \WP_Post $post Post object. * @param int[] $category_term_ids Category term IDs from the FAQ wrapper block. * @return array|null */ private static function build_item_from_block( $block, $post, $category_term_ids = [] ) { $texts = self::extract_item_texts( $block ); $question = $texts['question']; $answer = $texts['answer']; $answer_html = $texts['answer_html']; if ( '' === $question || '' === $answer ) { return null; } return [ 'title' => $question, 'question' => $question, 'answer' => $answer, 'answer_html' => $answer_html, 'anchor' => self::extract_block_anchor( $block ), 'post_id' => $post->ID, 'post_type' => $post->post_type, 'permalink' => get_permalink( $post ), 'post_title' => get_the_title( $post ), 'source_lang' => Polylang::get_post_language( (int) $post->ID ), 'category_term_ids' => array_values( array_map( 'intval', (array) $category_term_ids ) ), ]; } /** * Extract a stable HTML anchor for a FAQ item block. * * @param array $block Block data. * @return string */ private static function extract_block_anchor( $block ) { $attrs = $block['attrs'] ?? []; if ( ! empty( $attrs['anchor'] ) ) { return sanitize_title( (string) $attrs['anchor'] ); } if ( ! empty( $block['innerHTML'] ) && preg_match( '/]*\sid=["\']([^"\']+)["\']/i', (string) $block['innerHTML'], $matches ) ) { return sanitize_title( $matches[1] ); } return ''; } /** * Remove internal entity metadata before standalone JSON-LD output. * * @param array> $entities Schema entities. * @return array> */ public static function prepare_standalone_schema_entities( $entities ) { $prepared = []; foreach ( $entities as $entity ) { if ( ! is_array( $entity ) ) { continue; } unset( $entity['anchor'] ); $prepared[] = $entity; } return $prepared; } /** * Extract the question text from a block. * * @param array $block Block data. * @return string */ private static function extract_question( $block ) { $attrs = $block['attrs'] ?? []; foreach ( [ 'title', 'summary', 'question', 'heading' ] as $key ) { if ( ! empty( $attrs[ $key ] ) ) { $question = trim( wp_strip_all_tags( (string) $attrs[ $key ] ) ); return self::normalize_question_text( $question ); } } $block_name = $block['blockName'] ?? ''; if ( self::is_details_block( $block_name ) && ! empty( $block['innerHTML'] ) ) { $summary = self::extract_summary_from_html( (string) $block['innerHTML'] ); if ( '' !== $summary ) { return $summary; } } return ''; } /** * Whether a block name represents a core Details FAQ item. * * @param string $block_name Block name. * @return bool */ private static function is_details_block( $block_name ) { if ( ! is_string( $block_name ) || '' === $block_name ) { return false; } return 'core/details' === $block_name || false !== strpos( $block_name, '/details' ); } /** * Parse question text from a Details block summary element. * * @param string $html Details innerHTML. * @return string */ private static function extract_summary_from_html( $html ) { if ( ! preg_match( '/]*>(.*?)<\/summary>/is', $html, $matches ) ) { return ''; } return self::normalize_question_text( wp_strip_all_tags( $matches[1] ) ); } /** * Strip Details wrapper markup so answer text excludes the question summary. * * @param string $html Details innerHTML. * @return string */ private static function strip_details_wrapper_html( $html ) { $html = preg_replace( '/]*>.*?<\/summary>/is', '', $html ); $html = preg_replace( '/<\/?details[^>]*>/i', '', $html ); return trim( (string) $html ); } /** * Extract question/answer using accordion child blocks when needed. * * @param array $block Block data. * @return array{question:string,answer:string,answer_html:string} */ private static function extract_item_texts( $block ) { $block_name = $block['blockName'] ?? ''; $is_details = self::is_details_block( $block_name ); $question = self::extract_question( $block ); $answer_text = ''; $answer_html = ''; $inner = $block['innerBlocks'] ?? []; // Accordion-style: first inner block is the heading, rest is the answer. if ( '' === $question && ! empty( $inner ) && ! $is_details ) { $question = self::normalize_question_text( self::render_blocks_to_text( [ $inner[0] ] ) ); if ( count( $inner ) > 1 ) { $answer_text = self::render_blocks_to_text( array_slice( $inner, 1 ) ); $answer_html = self::render_blocks_to_html( array_slice( $inner, 1 ) ); } } if ( '' === $answer_text && ! empty( $inner ) ) { $answer_text = self::render_blocks_to_text( $inner ); } if ( '' === $answer_html && ! empty( $inner ) ) { $answer_html = self::render_blocks_to_html( $inner ); } if ( '' === $answer_text && ! empty( $block['innerHTML'] ) ) { $html_for_answer = (string) $block['innerHTML']; if ( $is_details ) { $html_for_answer = self::strip_details_wrapper_html( $html_for_answer ); } $answer_text = trim( wp_strip_all_tags( $html_for_answer ) ); } if ( '' === $answer_html && ! empty( $block['innerHTML'] ) ) { $html_for_answer = (string) $block['innerHTML']; if ( $is_details ) { $html_for_answer = self::strip_details_wrapper_html( $html_for_answer ); } $answer_html = trim( $html_for_answer ); } return [ 'question' => trim( $question ), 'answer' => trim( $answer_text ), 'answer_html' => trim( $answer_html ), ]; } /** * Extract the answer text from a block. * * @param array $block Block data. * @return string */ private static function extract_answer( $block ) { $answer = ''; if ( ! empty( $block['innerBlocks'] ) ) { $answer = self::render_blocks_to_text( $block['innerBlocks'] ); } if ( '' === $answer && ! empty( $block['innerHTML'] ) ) { $answer = trim( wp_strip_all_tags( $block['innerHTML'] ) ); } return trim( $answer ); } /** * Render blocks to plain text. * * @param array $blocks Parsed blocks. * @return string */ private static function render_blocks_to_text( $blocks ) { $text = ''; foreach ( $blocks as $block ) { $html = render_block( $block ); $text .= ' ' . wp_strip_all_tags( $html ); } return trim( $text ); } /** * Render blocks to HTML. * * @param array $blocks Parsed blocks. * @return string */ private static function render_blocks_to_html( $blocks ) { $html = ''; foreach ( $blocks as $block ) { $html .= render_block( $block ); } return trim( $html ); } /** * Build schema entities from FAQ items for the current post. * * @param array> $items FAQ items. * @return array> */ private static function build_schema_entities( $items ) { $entities = []; $seen = []; foreach ( $items as $item ) { $key = self::normalize_question_key( $item['question'] . '|' . $item['answer'] ); if ( isset( $seen[ $key ] ) ) { continue; } $entities[] = [ '@type' => 'Question', 'name' => $item['question'], 'acceptedAnswer' => [ '@type' => 'Answer', 'text' => $item['answer'], ], 'anchor' => isset( $item['anchor'] ) ? (string) $item['anchor'] : '', ]; $seen[ $key ] = true; } return $entities; } /** * Aggregate FAQ items by question for CPT storage. * * @param array> $items FAQ items. * @return array> */ private static function aggregate_items( $items ) { $aggregated = []; foreach ( $items as $item ) { $key = self::normalize_question_key( $item['question'] ); if ( '' === $key ) { continue; } if ( ! isset( $aggregated[ $key ] ) ) { $aggregated[ $key ] = [ 'question' => $item['question'], 'answers' => [], 'answers_html' => [], 'source_titles' => [], 'source_urls' => [], 'used_in_posts' => [], 'used_in_post_types' => [], 'category_term_ids' => [], 'primary_term_id' => 0, ]; } $aggregated[ $key ]['answers'][] = $item['answer']; $aggregated[ $key ]['answers_html'][] = $item['answer_html']; $aggregated[ $key ]['source_titles'][] = $item['post_title']; $post_id = (int) ( $item['post_id'] ?? 0 ); if ( $post_id > 0 ) { $aggregated[ $key ]['used_in_posts'][] = $post_id; } elseif ( ! empty( $item['permalink'] ) && is_string( $item['permalink'] ) ) { $aggregated[ $key ]['source_urls'][] = [ 'title' => (string) ( $item['post_title'] ?? '' ), 'url' => $item['permalink'], 'post_type' => (string) ( $item['post_type'] ?? '' ), 'post_type_label' => (string) ( $item['post_type_label'] ?? $item['post_type'] ?? '' ), ]; } if ( ! empty( $item['post_type'] ) ) { $aggregated[ $key ]['used_in_post_types'][] = $item['post_type']; } if ( ! empty( $item['category_term_ids'] ) && is_array( $item['category_term_ids'] ) ) { $aggregated[ $key ]['category_term_ids'] = array_merge( $aggregated[ $key ]['category_term_ids'], $item['category_term_ids'] ); if ( empty( $aggregated[ $key ]['primary_term_id'] ) ) { $aggregated[ $key ]['primary_term_id'] = (int) $item['category_term_ids'][0]; } } } foreach ( $aggregated as $key => $data ) { $aggregated[ $key ]['answers'] = self::unique_values( $data['answers'] ); $aggregated[ $key ]['answers_html'] = self::unique_values( $data['answers_html'] ); $aggregated[ $key ]['source_titles'] = self::unique_values( $data['source_titles'] ); $aggregated[ $key ]['source_urls'] = self::unique_values( $data['source_urls'] ?? [] ); $aggregated[ $key ]['used_in_posts'] = self::unique_values( $data['used_in_posts'] ); $aggregated[ $key ]['used_in_post_types'] = self::unique_values( $data['used_in_post_types'] ); $aggregated[ $key ]['category_term_ids'] = self::unique_values( $data['category_term_ids'] ?? [] ); $aggregated[ $key ]['count_usage'] = count( $aggregated[ $key ]['used_in_posts'] ) + count( $aggregated[ $key ]['source_urls'] ); } return $aggregated; } /** * Assign FAQ categories to a registry post from aggregated scan data. * * @param int $post_id Registry post ID. * @param array $data Aggregated FAQ data. */ private static function sync_registry_categories( $post_id, $data ) { $taxonomy = Settings::get_taxonomy(); if ( ! taxonomy_exists( $taxonomy ) ) { return; } $term_ids = []; if ( ! empty( $data['category_term_ids'] ) && is_array( $data['category_term_ids'] ) ) { $term_ids = array_values( array_unique( array_filter( array_map( 'intval', $data['category_term_ids'] ) ) ) ); } /** * Filter category term IDs before they are saved on a registry post. * * @param int[] $term_ids Term IDs. * @param int $post_id Registry post ID. * @param array $data Aggregated FAQ data. */ $term_ids = apply_filters( 'forwp_faq_registry_term_ids', $term_ids, $post_id, $data ); wp_set_object_terms( $post_id, $term_ids, $taxonomy, false ); $primary = (int) ( $data['primary_term_id'] ?? 0 ); if ( $primary > 0 && in_array( $primary, $term_ids, true ) ) { update_post_meta( $post_id, Faq_Terms::PRIMARY_META, $primary ); } elseif ( ! empty( $term_ids ) ) { update_post_meta( $post_id, Faq_Terms::PRIMARY_META, (int) $term_ids[0] ); } else { delete_post_meta( $post_id, Faq_Terms::PRIMARY_META ); } if ( Polylang::is_active() && ! empty( $data['used_in_posts'] ) && is_array( $data['used_in_posts'] ) ) { $source_post_id = (int) $data['used_in_posts'][0]; $lang = Polylang::get_post_language( $source_post_id ); if ( '' !== $lang ) { Polylang::set_post_language( $post_id, $lang ); } } } /** * Sync aggregated data into the FAQ CPT. * * @param array> $aggregated Aggregated FAQ data. */ private static function sync_faq_posts( $aggregated ) { $existing_ids = get_posts( [ 'post_type' => Settings::get_post_type(), 'post_status' => 'any', 'fields' => 'ids', 'posts_per_page' => -1, ] ); $existing_map = []; foreach ( $existing_ids as $faq_id ) { $faq = get_post( $faq_id ); if ( $faq instanceof \WP_Post ) { $existing_map[ self::normalize_question_key( $faq->post_title ) ] = $faq_id; } } $handled_ids = []; foreach ( $aggregated as $key => $data ) { $post_id = $existing_map[ $key ] ?? 0; $post_args = [ 'post_title' => $data['question'], 'post_type' => Settings::get_post_type(), 'post_status' => 'publish', ]; if ( $post_id ) { $post_args['ID'] = $post_id; $result = wp_update_post( $post_args, true ); } else { $result = wp_insert_post( $post_args, true ); } if ( is_wp_error( $result ) ) { continue; } $post_id = (int) $result; update_post_meta( $post_id, 'answers', $data['answers'] ); update_post_meta( $post_id, 'answers_html', $data['answers_html'] ); update_post_meta( $post_id, 'source_titles', $data['source_titles'] ); update_post_meta( $post_id, 'source_urls', $data['source_urls'] ?? [] ); update_post_meta( $post_id, 'original_question', $data['question'] ); update_post_meta( $post_id, 'used_in_posts', $data['used_in_posts'] ); update_post_meta( $post_id, 'used_in_post_types', $data['used_in_post_types'] ); update_post_meta( $post_id, 'count_usage', $data['count_usage'] ); self::sync_registry_categories( $post_id, $data ); $handled_ids[] = $post_id; } foreach ( $existing_ids as $faq_id ) { if ( ! in_array( $faq_id, $handled_ids, true ) ) { wp_delete_post( $faq_id, true ); } } } /** * Add admin columns for FAQ CPT list table. * * @param array $columns Columns. * @return array */ public static function add_admin_columns( $columns ) { $columns['answers'] = __( 'Answers', '4wp-faq' ); $columns['answers_html'] = __( 'Answers (HTML)', '4wp-faq' ); $columns['usage'] = __( 'Usage', '4wp-faq' ); return $columns; } /** * Render admin column values. * * @param string $column Column key. * @param int $post_id Post ID. */ public static function render_admin_column( $column, $post_id ) { if ( 'answers' === $column ) { $answers = get_post_meta( $post_id, 'answers', true ); $answers = is_array( $answers ) ? $answers : []; if ( empty( $answers ) ) { echo '—'; return; } $sample = wp_trim_words( (string) $answers[0], 16, '…' ); echo esc_html( count( $answers ) . ' • ' . $sample ); return; } if ( 'answers_html' === $column ) { $answers_html = get_post_meta( $post_id, 'answers_html', true ); $answers_html = is_array( $answers_html ) ? $answers_html : []; if ( empty( $answers_html ) ) { echo '—'; return; } $sample = wp_trim_words( wp_strip_all_tags( (string) $answers_html[0] ), 16, '…' ); echo esc_html( count( $answers_html ) . ' • ' . $sample ); return; } if ( 'usage' === $column ) { $count = (int) get_post_meta( $post_id, 'count_usage', true ); echo esc_html( $count ); } } /** * Register meta boxes for FAQ CPT edit screen. * * @param \WP_Post $post Post object. */ public static function register_meta_boxes( $post ) { add_meta_box( 'forwp_faq_details', __( 'FAQ Details', '4wp-faq' ), [ __CLASS__, 'render_meta_box' ], Settings::get_post_type(), 'normal', 'default' ); add_meta_box( 'forwp_faq_usage', __( 'Used In', '4wp-faq' ), [ __CLASS__, 'render_usage_meta_box' ], Settings::get_post_type(), 'side', 'default' ); } /** * Render FAQ meta box content. * * @param \WP_Post $post Post object. */ public static function render_meta_box( $post ) { $answers = get_post_meta( $post->ID, 'answers', true ); $answers = is_array( $answers ) ? $answers : []; $answers_html = get_post_meta( $post->ID, 'answers_html', true ); $answers_html = is_array( $answers_html ) ? $answers_html : []; $original_question = get_post_meta( $post->ID, 'original_question', true ); $source_titles = get_post_meta( $post->ID, 'source_titles', true ); $source_titles = is_array( $source_titles ) ? $source_titles : []; ?>

—

—

—

—

—

post_title ); $answer = isset( $answers[0] ) ? trim( (string) $answers[0] ) : ''; if ( '' !== $question && '' !== $answer ) { $schema = wp_json_encode( [ '@context' => 'https://schema.org', '@type' => 'FAQPage', 'mainEntity' => [ [ '@type' => 'Question', 'name' => $question, 'acceptedAnswer' => [ '@type' => 'Answer', 'text' => $answer, ], ], ], ] ); } $schema_id = 'forwp-faq-schema-' . (int) $post->ID; $scan_url = wp_nonce_url( admin_url( 'admin-post.php?action=forwp_faq_scan' ), 'forwp_faq_scan' ); ?>

—

ID, 'used_in_posts', true ); $used_in_posts = is_array( $used_in_posts ) ? array_values( $used_in_posts ) : []; $groups = []; foreach ( $used_in_posts as $post_id ) { $source_post = get_post( (int) $post_id ); if ( ! $source_post instanceof \WP_Post ) { continue; } $post_type = $source_post->post_type; if ( ! isset( $groups[ $post_type ] ) ) { $groups[ $post_type ] = []; } $groups[ $post_type ][] = $source_post; } $count_usage = (int) get_post_meta( $post->ID, 'count_usage', true ); ?>

—

$items ) : ?> labels->name ) ? $type_object->labels->name : $post_type; ?>

:

    ID ); $status = get_post_status( $source_post ); $status_obj = $status ? get_post_status_object( $status ) : null; $status_label = $status_obj && ! empty( $status_obj->label ) ? $status_obj->label : $status; $title = $source_post->post_title ? $source_post->post_title : $source_post->ID; ?>