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(
'
',
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 : [];
?>
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;
?>
-