[ 'script' => self::LIST_SCRIPT, 'style' => self::LIST_STYLE, 'render' => [ __CLASS__, 'render_list' ], ], 'faq-card' => [ 'script' => self::CARD_SCRIPT, 'style' => self::CARD_STYLE, 'render' => [ __CLASS__, 'render_card' ], ], 'faq-categories' => [ 'script' => self::CATEGORIES_SCRIPT, 'style' => self::CATEGORIES_STYLE, 'render' => [ __CLASS__, 'render_categories' ], ], 'faq-count' => [ 'script' => self::COUNT_SCRIPT, 'style' => self::LIST_STYLE, 'render' => [ __CLASS__, 'render_count' ], ], 'faq-no-results' => [ 'script' => self::LIST_SCRIPT, 'style' => self::LIST_STYLE, 'render' => [ __CLASS__, 'render_no_results' ], ], ]; foreach ( $blocks as $folder => $config ) { $path = FORWP_FAQ_PLUGIN_DIR . 'blocks/' . $folder; if ( ! is_readable( $path . '/block.json' ) ) { continue; } register_block_type( $path, [ 'editor_script' => $config['script'], 'style' => $config['style'], 'editor_style' => $config['style'], 'render_callback' => $config['render'], ] ); } } /** * Register built editor scripts, view script, and front-end styles. */ private static function register_assets() { $scripts = [ self::LIST_SCRIPT => 'faq-list', self::CARD_SCRIPT => 'faq-card', self::CATEGORIES_SCRIPT => 'faq-categories', self::COUNT_SCRIPT => 'faq-count', ]; foreach ( $scripts as $handle => $file ) { $asset_file = FORWP_FAQ_PLUGIN_DIR . 'build/' . $file . '.asset.php'; if ( ! is_readable( $asset_file ) ) { continue; } $asset = include $asset_file; wp_register_script( $handle, FORWP_FAQ_PLUGIN_URL . 'build/' . $file . '.js', isset( $asset['dependencies'] ) ? $asset['dependencies'] : [], isset( $asset['version'] ) ? $asset['version'] : FORWP_FAQ_VERSION, true ); } if ( function_exists( 'wp_register_script_module' ) ) { $view_file = FORWP_FAQ_PLUGIN_DIR . 'assets/faq-view.js'; if ( is_readable( $view_file ) ) { wp_register_script_module( self::VIEW_SCRIPT, FORWP_FAQ_PLUGIN_URL . 'assets/faq-view.js', [ [ 'id' => '@wordpress/interactivity', 'import' => 'static', ], ], (string) filemtime( $view_file ) ); } } $card_style = FORWP_FAQ_PLUGIN_DIR . 'build/style-faq-card.css'; $list_style = FORWP_FAQ_PLUGIN_DIR . 'build/style-faq-list.css'; $categories_style = FORWP_FAQ_PLUGIN_DIR . 'build/style-faq-categories.css'; if ( is_readable( $card_style ) ) { wp_register_style( self::CARD_STYLE, FORWP_FAQ_PLUGIN_URL . 'build/style-faq-card.css', [], (string) filemtime( $card_style ) ); } if ( is_readable( $list_style ) ) { wp_register_style( self::LIST_STYLE, FORWP_FAQ_PLUGIN_URL . 'build/style-faq-list.css', is_readable( $card_style ) ? [ self::CARD_STYLE ] : [], (string) filemtime( $list_style ) ); } if ( is_readable( $categories_style ) ) { wp_register_style( self::CATEGORIES_STYLE, FORWP_FAQ_PLUGIN_URL . 'build/style-faq-categories.css', [], (string) filemtime( $categories_style ) ); } } /** * Shared editor config for list/card/categories inspectors. */ public static function localize_editor_scripts() { $config = [ 'registrySetupComplete' => Settings::is_setup_complete(), 'postType' => Settings::get_post_type(), 'taxonomy' => Settings::get_taxonomy(), 'categoriesPath' => '/forwp-faq/v1/editor/categories', 'seoUrlsEnabled' => Settings::is_seo_urls_enabled(), ]; $registry = \WP_Block_Type_Registry::get_instance(); foreach ( [ self::LIST_BLOCK, self::CARD_BLOCK, self::CATEGORIES_BLOCK, self::COUNT_BLOCK ] as $block_name ) { $block = $registry->get_registered( $block_name ); if ( ! $block || empty( $block->editor_script_handles ) ) { continue; } foreach ( $block->editor_script_handles as $handle ) { wp_localize_script( $handle, 'forwpFaqDisplay', $config ); } } } /** * Drop attributes the editor added but this block does not register. * * Other plugins attach attributes to every block in the editor. The block * renderer rejects unknown keys with HTTP 400, and the preview then shows * "Error loading block: [object Object]". * * @param mixed $result Response to replace, or null to continue. * @param \WP_REST_Server $server Server instance. * @param \WP_REST_Request $request Request used to generate the response. * @return mixed */ public static function strip_unknown_preview_attributes( $result, $server, $request ) { unset( $server ); if ( null !== $result || ! $request instanceof \WP_REST_Request ) { return $result; } $route = $request->get_route(); $prefix = '/wp/v2/block-renderer/'; if ( ! is_string( $route ) || ! str_starts_with( $route, $prefix ) ) { return $result; } $name = rawurldecode( substr( $route, strlen( $prefix ) ) ); $ours = [ self::LIST_BLOCK, self::CARD_BLOCK, self::CATEGORIES_BLOCK, self::COUNT_BLOCK ]; if ( ! in_array( $name, $ours, true ) ) { return $result; } // Polylang does not filter REST queries: preview in the edited page's language, like the front end. $post_id = (int) $request->get_param( 'post_id' ); if ( $post_id > 0 ) { $lang = Polylang::get_post_language( $post_id ); if ( '' !== $lang ) { Polylang::force_language( $lang ); } } $attributes = $request->get_param( 'attributes' ); if ( ! is_array( $attributes ) ) { return $result; } $block = \WP_Block_Type_Registry::get_instance()->get_registered( $name ); if ( ! $block ) { return $result; } $allowed = array_fill_keys( array_keys( $block->get_attributes() ), true ); $request->set_param( 'attributes', array_intersect_key( $attributes, $allowed ) ); return $result; } /** * Bind a core Search block to the FAQ Interactivity store when opted in. * * @param string $content Block HTML. * @param array $block Parsed block. * @return string */ public static function render_related_search( $content, $block ) { $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : []; if ( empty( $attrs['forwpFaqFilter'] ) || ! is_string( $content ) || '' === $content ) { return $content; } if ( ! class_exists( '\WP_HTML_Tag_Processor' ) ) { return $content; } self::ensure_runtime(); $processor = new \WP_HTML_Tag_Processor( $content ); if ( $processor->next_tag( 'form' ) ) { $processor->set_attribute( 'data-wp-interactive', self::STORE ); $processor->set_attribute( 'data-wp-on--submit', 'actions.preventSearchSubmit' ); } $processor = new \WP_HTML_Tag_Processor( $processor->get_updated_html() ); if ( $processor->next_tag( 'input' ) ) { $processor->set_attribute( 'data-wp-interactive', self::STORE ); $processor->set_attribute( 'data-wp-on--input', 'actions.setSearch' ); } return $processor->get_updated_html(); } /** * Render 4WP FAQ List. * * @param array $attributes Block attributes. * @param string $content Inner content. * @param \WP_Block $block Block instance. * @return string */ public static function render_list( $attributes, $content, $block ) { unset( $content ); $layout = isset( $attributes['layout'] ) ? sanitize_key( (string) $attributes['layout'] ) : 'grouped'; $filters = self::resolve_shared_term_filters( $attributes['includeTermIds'] ?? [], $attributes['excludeTermIds'] ?? [] ); $include_term_ids = $filters['includeTermIds']; $exclude_term_ids = $filters['excludeTermIds']; $card_attrs = self::resolve_card_attributes( $attributes, $block ); $active = Faq_Filter::get_active_slug(); if ( 'flat' !== $layout ) { $layout = 'grouped'; } self::ensure_runtime(); $group_title = self::group_title_vars( $attributes['groupTitleStyle'] ?? [] ); $wrapper_attrs = [ 'class' => trim( 'forwp-faq-list is-layout-' . $layout . ' ' . $group_title['class'] ), 'data-wp-interactive' => self::STORE, ]; $typo_style = implode( ';', array_filter( [ self::typography_style_attr( $card_attrs ), $group_title['style'] ] ) ); if ( '' !== $typo_style ) { $wrapper_attrs['style'] = $typo_style; } $wrapper = get_block_wrapper_attributes( $wrapper_attrs ); if ( ! Settings::is_setup_complete() ) { return sprintf( '

%s

', $wrapper, esc_html__( 'Complete 4WP FAQ registry setup to display this list.', '4wp-faq' ) ); } $html = '
'; $preview_limit = '' === $active ? Settings::get_preview_per_category() : 0; $seo_urls = self::document_uses_seo_urls(); if ( 'flat' === $layout ) { $posts = Registry_Content::get_visible_posts( $include_term_ids, $exclude_term_ids, $active, $preview_limit, 'flat' ); $html .= self::render_items( $posts, $card_attrs ); } else { $groups = Registry_Content::get_visible_group_tree( $include_term_ids, $exclude_term_ids, $active, $preview_limit ); if ( empty( $groups ) ) { $html .= self::render_empty(); } else { $html .= self::render_group_tree( $groups, $active, $seo_urls, $card_attrs ); } } $html .= self::render_list_no_results( $block ); $html .= '
'; return $html; } /** * The list's inner No Results block, or a default message for lists saved before it existed. * * @param \WP_Block|null $block List block instance. * @return string */ private static function render_list_no_results( $block ) { if ( $block instanceof \WP_Block && ! empty( $block->parsed_block['innerBlocks'] ) ) { foreach ( $block->parsed_block['innerBlocks'] as $inner ) { if ( self::NO_RESULTS_BLOCK === ( $inner['blockName'] ?? '' ) ) { return render_block( $inner ); } } } return ''; } /** * Render 4WP FAQ No Results (hidden until a search matches nothing). * * @param array $attributes Block attributes. * @param string $content Inner blocks HTML. * @return string */ public static function render_no_results( $attributes, $content = '' ) { unset( $attributes ); return ''; } /** * @return array */ private static function no_results_attrs() { return [ 'class' => 'forwp-faq-list__no-results', 'data-wp-interactive' => self::STORE, 'data-wp-bind--hidden' => '!state.hasNoResults', ]; } /** * @param array $attrs Attribute map. * @return string */ private static function plain_attributes( $attrs ) { $out = ''; foreach ( $attrs as $name => $value ) { $out .= ' ' . $name . '="' . esc_attr( $value ) . '"'; } return ltrim( $out ); } /** * Render 4WP FAQ Categories (sidebar / navigation). * * @param array $attributes Block attributes. * @return string */ public static function render_categories( $attributes ) { $orientation = isset( $attributes['orientation'] ) ? sanitize_key( (string) $attributes['orientation'] ) : 'vertical'; $show_all = ! isset( $attributes['showAll'] ) || ! empty( $attributes['showAll'] ); $show_count = ! isset( $attributes['showCount'] ) || ! empty( $attributes['showCount'] ); $collapse_children = ! isset( $attributes['collapseChildren'] ) || ! empty( $attributes['collapseChildren'] ); $seo_urls = Settings::is_seo_urls_enabled() && ! empty( $attributes['seoUrls'] ); $filters = self::resolve_shared_term_filters( $attributes['includeTermIds'] ?? [], $attributes['excludeTermIds'] ?? [] ); $include_term_ids = $filters['includeTermIds']; $exclude_term_ids = $filters['excludeTermIds']; $all_label = isset( $attributes['allLabel'] ) && is_string( $attributes['allLabel'] ) && '' !== $attributes['allLabel'] ? $attributes['allLabel'] : __( 'All categories', '4wp-faq' ); $nav_label = isset( $attributes['label'] ) && is_string( $attributes['label'] ) ? trim( $attributes['label'] ) : ''; if ( 'horizontal' !== $orientation ) { $orientation = 'vertical'; } self::ensure_runtime(); $nav_class = 'forwp-faq-categories is-orientation-' . $orientation; if ( $collapse_children ) { $nav_class .= ' is-collapsible'; } $wrapper_attrs = [ 'class' => $nav_class, 'data-wp-interactive' => self::STORE, 'data-wp-class--is-menu-open' => 'state.isMenuOpen', ]; $size_style = self::categories_size_vars( $attributes ); if ( '' !== $size_style ) { $wrapper_attrs['style'] = $size_style; } $wrapper = get_block_wrapper_attributes( $wrapper_attrs ); if ( ! Settings::is_setup_complete() ) { return sprintf( '', $wrapper, esc_html__( 'Complete 4WP FAQ registry setup to display categories.', '4wp-faq' ) ); } $terms = Registry_Content::get_nav_tree( $include_term_ids, $exclude_term_ids ); $active = Faq_Filter::get_active_slug(); $label_id = function_exists( 'wp_unique_id' ) ? wp_unique_id( 'forwp-faq-cats-label-' ) : 'forwp-faq-cats-label'; $list_id = function_exists( 'wp_unique_id' ) ? wp_unique_id( 'forwp-faq-cats-list-' ) : 'forwp-faq-cats-list'; $html = ''; return $html; } /** * Render 4WP FAQ Count (live number for All / category URL / search). * * @param array $attributes Block attributes. * @return string */ public static function render_count( $attributes ) { unset( $attributes ); return self::count_markup( true ); } /** * Count span markup. * * @param bool $use_block_wrapper False for the shortcode: otherwise the enclosing block's classes (e.g. alignfull) leak in. * @return string */ private static function count_markup( $use_block_wrapper ) { self::ensure_runtime(); $context = self::get_list_query_context(); $totals = self::count_totals( $context ); $count = (int) ( $totals[ $context['activeSlug'] ] ?? 0 ); $attrs = [ 'class' => 'forwp-faq-count', 'data-wp-interactive' => self::STORE, 'data-wp-text' => 'state.visibleCount', ]; $wrapper = $use_block_wrapper ? get_block_wrapper_attributes( $attrs ) : self::plain_attributes( $attrs ); return '' . esc_html( (string) $count ) . ''; } /** * Question totals matching the category nav counts. * * Key '' = sum of top-level category counts; each slug = that category's nav count. * * @param array $context From get_list_query_context(). * @return array */ private static function count_totals( $context ) { static $cache = null; if ( null !== $cache ) { return $cache; } $totals = [ '' => 0 ]; $walk = static function ( $nodes, $top ) use ( &$walk, &$totals ) { foreach ( (array) $nodes as $node ) { $term = $node['term'] ?? null; if ( ! $term instanceof \WP_Term ) { continue; } $count = isset( $node['inclusive_count'] ) ? (int) $node['inclusive_count'] : (int) $term->count; $totals[ $term->slug ] = $count; if ( $top ) { $totals[''] += $count; } if ( ! empty( $node['children'] ) ) { $walk( $node['children'], false ); } } }; $walk( Registry_Content::get_nav_tree( $context['includeTermIds'], $context['excludeTermIds'] ), true ); $cache = $totals; return $totals; } /** * Shortcode [forwp_faq_count] — same live number as 4WP FAQ Count. * * @param array|string $atts Shortcode attributes. None supported. * @return string */ public static function shortcode_count( $atts = [] ) { unset( $atts ); return self::count_markup( false ); } /** * Include/exclude, layout, and preview settings for the current document. * * @return array{includeTermIds: int[], excludeTermIds: int[], layout: string, seoUrls: bool, activeSlug: string, previewLimit: int} */ public static function get_list_query_context() { $filters = self::resolve_shared_term_filters( [], [] ); $layout = 'grouped'; $seo = false; foreach ( self::get_document_contents() as $content ) { self::inspect_display_blocks( parse_blocks( $content ), $layout, $seo ); } $active = Faq_Filter::get_active_slug(); return [ 'includeTermIds' => $filters['includeTermIds'], 'excludeTermIds' => $filters['excludeTermIds'], 'layout' => $layout, 'seoUrls' => Settings::is_seo_urls_enabled() && $seo, 'activeSlug' => $active, 'previewLimit' => '' === $active ? Settings::get_preview_per_category() : 0, ]; } /** * Whether the current document contains a block. * * @param string $block_name Block name. * @return bool */ public static function document_has_block( $block_name ) { foreach ( self::get_document_contents() as $content ) { if ( has_block( $block_name, $content ) ) { return true; } } return false; } /** * Whether 4WP FAQ Categories has SEO URLs enabled in this document. * * @return bool */ private static function document_uses_seo_urls() { return ! empty( self::get_list_query_context()['seoUrls'] ); } /** * @param array[] $blocks Parsed blocks. * @param string $layout Layout found. * @param bool $seo SEO URLs found. */ private static function inspect_display_blocks( $blocks, &$layout, &$seo ) { foreach ( $blocks as $block ) { $name = isset( $block['blockName'] ) ? (string) $block['blockName'] : ''; $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : []; if ( self::LIST_BLOCK === $name && ! empty( $attrs['layout'] ) ) { $layout = sanitize_key( (string) $attrs['layout'] ); } if ( self::CATEGORIES_BLOCK === $name && ! empty( $attrs['seoUrls'] ) ) { $seo = true; } if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) { self::inspect_display_blocks( $block['innerBlocks'], $layout, $seo ); } } } /** * Union include/exclude from 4WP FAQ List and 4WP FAQ Categories in the same document. * * Empty include on one block inherits the sibling. Non-empty includes are unioned so the * nav never lists a category that was not queried, and the list includes every nav category. * Skipped in the editor REST renderer so live inspector attributes stay authoritative. * * @param mixed $own_include Own include term IDs. * @param mixed $own_exclude Own exclude term IDs. * @return array{includeTermIds: int[], excludeTermIds: int[]} */ private static function resolve_shared_term_filters( $own_include, $own_exclude ) { $own = [ 'includeTermIds' => Registry_Content::sanitize_term_ids( $own_include ), 'excludeTermIds' => Registry_Content::sanitize_term_ids( $own_exclude ), ]; if ( ! self::should_merge_document_filters() ) { return $own; } static $cache = null; if ( null === $cache ) { $include_sets = []; $exclude_ids = []; $found = false; foreach ( self::get_document_contents() as $content ) { $before = count( $include_sets ); self::collect_filters_from_blocks( parse_blocks( $content ), $include_sets, $exclude_ids ); if ( count( $include_sets ) > $before ) { $found = true; } } if ( ! $found ) { $cache = false; } else { $union = []; foreach ( $include_sets as $set ) { if ( empty( $set ) ) { continue; } foreach ( $set as $id ) { $union[] = (int) $id; } } $cache = [ 'includeTermIds' => array_values( array_unique( $union ) ), 'excludeTermIds' => array_values( array_unique( $exclude_ids ) ), ]; } } return false === $cache ? $own : $cache; } /** * Editor block-renderer REST requests send a single block; do not overlay saved siblings. * * @return bool */ private static function should_merge_document_filters() { if ( is_admin() ) { return false; } if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { return false; } return true; } /** * Post content plus the current FSE template (list and nav often live in different columns). * * @return string[] */ private static function get_document_contents() { $contents = []; $post = get_post(); if ( $post instanceof \WP_Post && is_string( $post->post_content ) && '' !== $post->post_content ) { $contents[] = $post->post_content; } global $_wp_current_template_content; if ( is_string( $_wp_current_template_content ) && '' !== $_wp_current_template_content ) { $contents[] = $_wp_current_template_content; } return $contents; } /** * @param array[] $blocks Parsed blocks. * @param int[][] $include_sets Collected include arrays (empty = all). * @param int[] $exclude_ids Collected exclude IDs. */ private static function collect_filters_from_blocks( $blocks, array &$include_sets, array &$exclude_ids ) { foreach ( $blocks as $block ) { $name = isset( $block['blockName'] ) ? (string) $block['blockName'] : ''; if ( self::LIST_BLOCK === $name || self::CATEGORIES_BLOCK === $name ) { $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : []; $include_sets[] = Registry_Content::sanitize_term_ids( $attrs['includeTermIds'] ?? [] ); foreach ( Registry_Content::sanitize_term_ids( $attrs['excludeTermIds'] ?? [] ) as $id ) { $exclude_ids[] = $id; } } if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) { self::collect_filters_from_blocks( $block['innerBlocks'], $include_sets, $exclude_ids ); } } } /** * Render FAQ Card from Query Loop / list context. * * @param array $attributes Block attributes. * @param string $content Inner content. * @param \WP_Block $block Block instance. * @return string */ public static function render_card( $attributes, $content, $block ) { unset( $content ); $post_id = 0; if ( isset( $block->context['postId'] ) ) { $post_id = (int) $block->context['postId']; } if ( $post_id <= 0 ) { $post_id = (int) get_the_ID(); } return self::render_card_for_post( $post_id, $attributes ); } /** * Card attributes from the inner FAQ Card, with list-level fallback (editor SSR). * * @param array $attributes List attributes. * @param \WP_Block|null $block List block instance. * @return array{displayMode: string, showSources: bool, sourcesLabel: string, sourcesSeparator: string, showPostType: bool, questionStyle: array, answerStyle: array} */ private static function resolve_card_attributes( $attributes, $block ) { $inner = []; if ( $block instanceof \WP_Block && ! empty( $block->parsed_block['innerBlocks'][0]['attrs'] ) ) { $inner = $block->parsed_block['innerBlocks'][0]['attrs']; } $display = isset( $inner['displayMode'] ) ? sanitize_key( (string) $inner['displayMode'] ) : ''; if ( '' === $display && isset( $attributes['cardDisplayMode'] ) ) { $display = sanitize_key( (string) $attributes['cardDisplayMode'] ); } if ( 'heading' !== $display ) { $display = 'accordion'; } if ( array_key_exists( 'showSources', $inner ) ) { $show_sources = ! empty( $inner['showSources'] ); } else { $show_sources = ! empty( $attributes['cardShowSources'] ); } if ( array_key_exists( 'sourcesLabel', $inner ) ) { $sources_label = sanitize_text_field( (string) $inner['sourcesLabel'] ); } elseif ( isset( $attributes['cardSourcesLabel'] ) ) { $sources_label = sanitize_text_field( (string) $attributes['cardSourcesLabel'] ); } else { $sources_label = __( 'Used in', '4wp-faq' ); } if ( array_key_exists( 'sourcesSeparator', $inner ) ) { $sources_separator = (string) $inner['sourcesSeparator']; } elseif ( isset( $attributes['cardSourcesSeparator'] ) ) { $sources_separator = (string) $attributes['cardSourcesSeparator']; } else { $sources_separator = ', '; } if ( array_key_exists( 'showPostType', $inner ) ) { $show_post_type = ! empty( $inner['showPostType'] ); } else { $show_post_type = ! empty( $attributes['cardShowPostType'] ); } $question_style = []; if ( isset( $inner['questionStyle'] ) && is_array( $inner['questionStyle'] ) ) { $question_style = $inner['questionStyle']; } elseif ( isset( $attributes['cardQuestionStyle'] ) && is_array( $attributes['cardQuestionStyle'] ) ) { $question_style = $attributes['cardQuestionStyle']; } $answer_style = []; if ( isset( $inner['answerStyle'] ) && is_array( $inner['answerStyle'] ) ) { $answer_style = $inner['answerStyle']; } elseif ( isset( $attributes['cardAnswerStyle'] ) && is_array( $attributes['cardAnswerStyle'] ) ) { $answer_style = $attributes['cardAnswerStyle']; } return [ 'displayMode' => $display, 'showSources' => $show_sources, 'sourcesLabel' => $sources_label, 'sourcesSeparator' => $sources_separator, 'showPostType' => $show_post_type, 'questionStyle' => self::sanitize_typography_style( $question_style ), 'answerStyle' => self::sanitize_typography_style( $answer_style ), ]; } /** * @param mixed $style Raw style object. * @return array Keys from typography_var_suffixes(). */ private static function sanitize_typography_style( $style ) { if ( ! is_array( $style ) ) { return []; } $out = []; if ( ! empty( $style['fontSize'] ) ) { $size = self::sanitize_css_size( $style['fontSize'] ); if ( '' !== $size ) { $out['fontSize'] = $size; } } if ( ! empty( $style['fontFamily'] ) ) { $family = self::sanitize_css_font_family( $style['fontFamily'] ); if ( '' !== $family ) { $out['fontFamily'] = $family; } } if ( ! empty( $style['fontWeight'] ) ) { $weight = sanitize_text_field( (string) $style['fontWeight'] ); if ( preg_match( '/^(normal|bold|[1-9]00)$/', $weight ) ) { $out['fontWeight'] = $weight; } } if ( ! empty( $style['color'] ) ) { $color = self::sanitize_css_color( $style['color'] ); if ( '' !== $color ) { $out['color'] = $color; } } $patterns = [ 'fontStyle' => '/^(normal|italic)$/', 'lineHeight' => '/^\d+(\.\d+)?(px|rem|em|%)?$/', 'letterSpacing' => '/^-?\d+(\.\d+)?(px|rem|em)$/', 'textDecoration' => '/^(none|underline|line-through)$/', 'textTransform' => '/^(none|uppercase|lowercase|capitalize)$/', ]; foreach ( $patterns as $key => $pattern ) { if ( ! isset( $style[ $key ] ) || '' === $style[ $key ] ) { continue; } $val = trim( sanitize_text_field( (string) $style[ $key ] ) ); if ( preg_match( $pattern, $val ) ) { $out[ $key ] = $val; } } return $out; } /** * Style keys → CSS custom property suffix (shared by q / a / g prefixes). * * @return array */ private static function typography_var_suffixes() { return [ 'fontSize' => 'size', 'fontFamily' => 'family', 'fontWeight' => 'weight', 'fontStyle' => 'style', 'lineHeight' => 'line', 'letterSpacing' => 'spacing', 'textDecoration' => 'decoration', 'textTransform' => 'transform', 'color' => 'color', ]; } /** * Allow hex, rgb/rgba/hsl, and CSS variables (incl. Gutenberg preset refs). * * @param mixed $value Raw color. * @return string */ private static function sanitize_css_color( $value ) { $raw = trim( (string) $value ); if ( '' === $raw ) { return ''; } // Gutenberg attribute form: var:preset|color|slug → CSS variable. if ( preg_match( '/^var:preset\|color\|([a-z0-9\-]+)$/i', $raw, $m ) ) { return 'var(--wp--preset--color--' . strtolower( $m[1] ) . ')'; } $hex = sanitize_hex_color( $raw ); if ( $hex ) { return $hex; } if ( preg_match( '/^var\(--wp--preset--color--[a-z0-9\-]+\)$/i', $raw ) ) { return $raw; } if ( preg_match( '/^var\(--[a-zA-Z0-9_\-]+\)$/', $raw ) ) { return $raw; } if ( preg_match( '/^rgba?\(\s*[\d.%\s,]+\s*(?:\/\s*[\d.]+\s*)?\)$/i', $raw ) ) { return $raw; } if ( preg_match( '/^hsla?\(\s*[\d.%\s,\/]+\s*\)$/i', $raw ) ) { return $raw; } return ''; } /** * @param mixed $value Raw size. * @return string */ private static function sanitize_css_size( $value ) { $value = trim( (string) $value ); if ( '' === $value ) { return ''; } if ( is_numeric( $value ) ) { return ( (float) $value ) . 'px'; } if ( preg_match( '/^(\d+(\.\d+)?)(px|rem|em|%)$/', $value ) ) { return $value; } if ( preg_match( '/^var\(--wp--preset--font-size--[a-z0-9\-]+\)$/', $value ) ) { return $value; } return ''; } /** * @param mixed $value Raw font-family. * @return string */ private static function sanitize_css_font_family( $value ) { $value = trim( (string) $value ); if ( '' === $value ) { return ''; } if ( preg_match( '/^var\(--wp--preset--font-family--[a-z0-9\-]+\)$/', $value ) ) { return $value; } // Allow simple family stacks: letters, spaces, commas, quotes, hyphens. if ( preg_match( '/^[a-zA-Z0-9\s,\-\'"\.]+$/', $value ) && strlen( $value ) <= 120 ) { return $value; } return ''; } /** * Build inline CSS custom properties for question/answer typography. * * @param array $card_attrs Card attributes. * @return string */ private static function typography_style_attr( $card_attrs ) { $question = isset( $card_attrs['questionStyle'] ) && is_array( $card_attrs['questionStyle'] ) ? $card_attrs['questionStyle'] : []; $answer = isset( $card_attrs['answerStyle'] ) && is_array( $card_attrs['answerStyle'] ) ? $card_attrs['answerStyle'] : []; $map = []; foreach ( self::typography_var_suffixes() as $key => $suffix ) { $map[ '--forwp-faq-q-' . $suffix ] = $question[ $key ] ?? ''; $map[ '--forwp-faq-a-' . $suffix ] = $answer[ $key ] ?? ''; } $parts = []; foreach ( $map as $prop => $val ) { $val = trim( (string) $val ); if ( '' === $val ) { continue; } $parts[] = $prop . ':' . $val; } return implode( ';', $parts ); } /** * Category title typography: CSS variables plus one class per set property. * * Classes keep theme heading styles untouched for properties left empty. * * @param mixed $style Raw groupTitleStyle attribute. * @return array{class: string, style: string} */ private static function group_title_vars( $style ) { $clean = self::sanitize_typography_style( $style ); $vars = []; $classes = []; foreach ( self::typography_var_suffixes() as $key => $suffix ) { if ( empty( $clean[ $key ] ) ) { continue; } $vars[] = '--forwp-faq-g-' . $suffix . ':' . $clean[ $key ]; $classes[] = 'has-g-' . $suffix; } return [ 'class' => implode( ' ', $classes ), 'style' => implode( ';', $vars ), ]; } /** * Nested category groups (parent wraps children). * * @param array $groups Nested groups. * @param string $active Active slug. * @param bool $seo_urls Pretty permalinks. * @param array $card_attrs Card attributes. * @param string[] $ancestors Ancestor slugs. * @return string */ private static function render_group_tree( $groups, $active, $seo_urls, $card_attrs, $ancestors = [] ) { $html = ''; foreach ( $groups as $group ) { $posts = isset( $group['posts'] ) && is_array( $group['posts'] ) ? $group['posts'] : []; $children = isset( $group['children'] ) && is_array( $group['children'] ) ? $group['children'] : []; if ( empty( $posts ) && empty( $children ) ) { continue; } $term = $group['term'] ?? null; $slug = $term instanceof \WP_Term ? $term->slug : ''; $title = $term instanceof \WP_Term ? Faq_Terms::get_display_title( $term ) : __( 'Uncategorized', '4wp-faq' ); $hidden = ( '' !== $active && $slug !== $active && ! in_array( $active, $ancestors, true ) ); $is_child = ! empty( $ancestors ); $truncated = ! empty( $group['truncated'] ); $group_class = 'forwp-faq-list__group' . ( $is_child ? ' is-child' : '' ); $branch = $ancestors; if ( '' !== $slug ) { $branch[] = $slug; } $html .= '
$slug, 'ancestors' => implode( ' ', $ancestors ), 'searchText' => self::group_search_text( $posts, $children ), ] ) . ' data-wp-bind--hidden="!state.isGroupVisible"' . ( $hidden ? ' hidden' : '' ) . '>'; $seo_term = Faq_Terms::get_seo_term(); $omit_title = $seo_term instanceof \WP_Term && $slug === $seo_term->slug; if ( ! $omit_title ) { $html .= '

' . esc_html( $title ) . '

'; } if ( ! empty( $posts ) ) { $html .= self::render_items( $posts, $card_attrs ); if ( $truncated && $term instanceof \WP_Term ) { $html .= self::render_see_all( $term, $seo_urls, (int) ( $group['total'] ?? 0 ) ); } } if ( ! empty( $children ) ) { $html .= '
'; $html .= self::render_group_tree( $children, $active, $seo_urls, $card_attrs, $branch ); $html .= '
'; } $html .= '
'; } return $html; } /** * Question and answer text for this group and its children, for search. * * @param \WP_Post[] $posts Posts rendered in the group. * @param array[] $children Nested groups. * @return string */ private static function group_search_text( $posts, $children ) { $parts = []; foreach ( $posts as $post ) { if ( ! $post instanceof \WP_Post ) { continue; } $answer = Registry_Content::get_answer( (int) $post->ID ); $parts[] = strtolower( trim( get_the_title( $post ) . ' ' . ( $answer['text'] ?? '' ) ) ); } foreach ( $children as $child ) { if ( ! is_array( $child ) ) { continue; } $child_posts = isset( $child['posts'] ) && is_array( $child['posts'] ) ? $child['posts'] : []; $child_nodes = isset( $child['children'] ) && is_array( $child['children'] ) ? $child['children'] : []; $parts[] = self::group_search_text( $child_posts, $child_nodes ); } return trim( implode( ' ', array_filter( $parts ) ) ); } /** * @param \WP_Post[] $posts Registry posts. * @param array $card_attrs Card attributes. * @return string */ private static function render_items( $posts, $card_attrs ) { if ( empty( $posts ) ) { return self::render_empty(); } $html = '
'; foreach ( $posts as $post ) { if ( ! $post instanceof \WP_Post ) { continue; } $html .= self::render_card_for_post( (int) $post->ID, $card_attrs ); } $html .= '
'; return $html; } /** * @return string */ private static function render_empty() { return '

' . esc_html__( 'No FAQ entries found.', '4wp-faq' ) . '

'; } /** * Markup for one registry FAQ card. * * @param int $post_id Registry post ID. * @param array $attributes Card attributes. * @return string */ public static function render_card_for_post( $post_id, $attributes ) { $post_id = (int) $post_id; $post = $post_id > 0 ? get_post( $post_id ) : null; if ( ! $post instanceof \WP_Post ) { return ''; } if ( Settings::is_setup_complete() && $post->post_type !== Settings::get_post_type() ) { return ''; } $display = isset( $attributes['displayMode'] ) ? sanitize_key( (string) $attributes['displayMode'] ) : 'accordion'; $show_sources = ! empty( $attributes['showSources'] ); $show_type = ! empty( $attributes['showPostType'] ); $sources_label = isset( $attributes['sourcesLabel'] ) ? sanitize_text_field( (string) $attributes['sourcesLabel'] ) : __( 'Used in', '4wp-faq' ); $sources_separator = isset( $attributes['sourcesSeparator'] ) ? (string) $attributes['sourcesSeparator'] : ', '; if ( 'heading' !== $display ) { $display = 'accordion'; } $question = get_the_title( $post ); $answer = Registry_Content::get_answer( $post_id ); $sources = $show_sources ? Registry_Content::get_sources( $post_id ) : []; $panel_id = 'forwp-faq-card-panel-' . $post_id; $term_slugs = Registry_Content::get_post_term_slugs_with_ancestors( $post_id ); $search = strtolower( trim( $question . ' ' . ( $answer['text'] ?? '' ) ) ); $active = Faq_Filter::get_active_slug(); $hidden = ( '' !== $active && ! in_array( $active, $term_slugs, true ) ); $extra = isset( $attributes['className'] ) ? trim( (string) $attributes['className'] ) : ''; $classes = trim( 'wp-block-forwp-faq-card forwp-faq-card is-display-' . $display . ' ' . $extra ); $style_attr = ''; $typo = self::typography_style_attr( [ 'questionStyle' => self::sanitize_typography_style( $attributes['questionStyle'] ?? [] ), 'answerStyle' => self::sanitize_typography_style( $attributes['answerStyle'] ?? [] ), ] ); if ( '' !== $typo ) { $style_attr = ' style="' . esc_attr( $typo ) . '"'; } $context = self::context_attr( [ 'cats' => implode( ' ', $term_slugs ), 'searchText' => $search, ] ); $hidden_attr = ' data-wp-bind--hidden="!state.isItemVisible"' . ( $hidden ? ' hidden' : '' ); $body = self::render_answer( $answer ); $body .= self::render_sources( $sources, $sources_label, $show_type, $sources_separator ); if ( 'heading' === $display ) { return sprintf( '

%5$s

%6$s
', esc_attr( $classes ), $context, $hidden_attr, $style_attr, esc_html( $question ), $body ); } return sprintf( '
%6$s
%7$s
', esc_attr( $classes ), $context, $hidden_attr, $style_attr, esc_attr( $panel_id ), esc_html( $question ), $body ); } /** * Drop color classes copied from dark page sections so the answer uses the card color. * * @param string $html Answer HTML. * @return string */ private static function strip_answer_color( $html ) { $html = preg_replace_callback( '/\sclass=("|\')([^"\']*)\1/', static function ( $matches ) { $classes = preg_split( '/\s+/', $matches[2], -1, PREG_SPLIT_NO_EMPTY ); $classes = array_values( array_filter( $classes, static function ( $class ) { if ( in_array( $class, [ 'has-text-color', 'has-background', 'has-link-color' ], true ) ) { return false; } return ! preg_match( '/^has-.+-(color|background-color)$/', $class ); } ) ); if ( ! $classes ) { return ''; } return ' class=' . $matches[1] . implode( ' ', $classes ) . $matches[1]; }, $html ); $html = preg_replace_callback( '/\sstyle=("|\')([^"\']*)\1/', static function ( $matches ) { $style = preg_replace( '/(?:^|;)\s*color\s*:[^;]*/i', '', $matches[2] ); $style = trim( (string) $style, " \t;" ); if ( '' === $style ) { return ''; } return ' style=' . $matches[1] . $style . $matches[1]; }, (string) $html ); return (string) $html; } /** * @param array{text: string, html: string} $answer Answer payload. * @return string */ private static function render_answer( $answer ) { $html = isset( $answer['html'] ) ? trim( (string) $answer['html'] ) : ''; $text = isset( $answer['text'] ) ? trim( (string) $answer['text'] ) : ''; if ( '' !== $html ) { return '
' . wp_kses_post( self::strip_answer_color( $html ) ) . '
'; } if ( '' !== $text ) { return '

' . esc_html( $text ) . '

'; } return ''; } /** * @param list $sources Sources. * @param string $sources_label Label placeholder. * @param bool $show_post_type Show CPT type. * @param string $separator Between items. * @return string */ private static function render_sources( $sources, $sources_label = '', $show_post_type = false, $separator = ', ' ) { if ( empty( $sources ) ) { return ''; } $html = '
'; if ( '' !== $sources_label ) { $html .= '

' . esc_html( $sources_label ) . '

'; } $html .= '

'; $items = []; foreach ( $sources as $source ) { $is_public = ! isset( $source['is_public'] ) || $source['is_public']; $item = $is_public ? '' . esc_html( $source['title'] ) . '' : '' . esc_html( $source['title'] ) . ''; if ( $show_post_type && ! empty( $source['post_type_label'] ) ) { $item .= ' ' . esc_html( $source['post_type_label'] ) . ''; } $items[] = $item; } $html .= implode( '' . esc_html( $separator ) . '', $items ); $html .= '

'; return $html; } /** * Nested category nav items. * * @param list $nodes Tree. * @param bool $seo_urls Pretty permalinks. * @param string $active Active slug. * @param bool $show_count Show counts. * @param bool $collapse_children Collapse nested lists by default. * @return string */ private static function render_category_nodes( $nodes, $seo_urls, $active, $show_count, $collapse_children = true ) { $html = ''; foreach ( $nodes as $node ) { $term = $node['term'] ?? null; if ( ! $term instanceof \WP_Term ) { continue; } $url = Faq_Filter::get_nav_url( $term->slug, $seo_urls ); $count = null; if ( $show_count ) { $count = isset( $node['inclusive_count'] ) ? (int) $node['inclusive_count'] : (int) $term->count; } $children = isset( $node['children'] ) && is_array( $node['children'] ) ? $node['children'] : []; $has_children = ! empty( $children ); $force_open = $collapse_children && $has_children && self::nav_node_contains_slug( $node, $active ); $inner = ''; $children_id = ''; if ( $has_children ) { $children_id = 'forwp-faq-cat-children-' . sanitize_html_class( $term->slug ); $inner = '
    slug, $term->name, $url, $seo_urls, $active, $count, $inner, $has_children, $collapse_children, $force_open, $children_id ); } return $html; } /** * Whether this nav node or a descendant matches the active slug. * * @param array{term?: \WP_Term, children?: array} $node Tree node. * @param string $slug Active slug. * @return bool */ private static function nav_node_contains_slug( $node, $slug ) { if ( ! is_string( $slug ) || '' === $slug ) { return false; } $term = $node['term'] ?? null; if ( $term instanceof \WP_Term && $term->slug === $slug ) { return true; } $children = isset( $node['children'] ) && is_array( $node['children'] ) ? $node['children'] : []; foreach ( $children as $child ) { if ( self::nav_node_contains_slug( $child, $slug ) ) { return true; } } return false; } /** * Block editor / REST preview: keep nested lists visible so the tree can be inspected. * * @return bool */ private static function is_editor_render() { return defined( 'REST_REQUEST' ) && REST_REQUEST; } /** * Link from a truncated All-view group to the full category. * * @param \WP_Term $term Term. * @param bool $seo_urls Pretty permalinks. * @param int $total Full count. * @return string */ private static function render_see_all( $term, $seo_urls, $total ) { unset( $total ); $url = Faq_Filter::get_category_url( $term->slug, $seo_urls ); /* translators: %s: FAQ category name */ $label = sprintf( __( 'View all in %s', '4wp-faq' ), Faq_Terms::get_display_title( $term ) ); return '

    ' . esc_html( $label ) . '

    '; } /** * CSS variables for the All link, category names, and the gap between them. * * @param array $attributes Block attributes. * @return string */ private static function categories_size_vars( $attributes ) { $map = [ 'allItemFontSize' => '--forwp-faq-all-size', 'itemFontSize' => '--forwp-faq-item-size', 'itemGap' => '--forwp-faq-item-gap', ]; $parts = []; foreach ( $map as $key => $var ) { $size = self::sanitize_css_size( $attributes[ $key ] ?? '' ); if ( '' !== $size ) { $parts[] = $var . ':' . $size; } } return implode( ';', $parts ); } /** * One category nav item. * * @param string $slug Term slug (empty = all). * @param string $label Label. * @param string $url Href. * @param bool $seo_urls Pretty permalinks. * @param string $active Active slug. * @param int|null $count Optional count. * @param string $inner Optional nested list HTML. * @param bool $has_children Whether the item has children. * @param bool $collapse_children Collapse nested lists by default. * @param bool $force_open Keep this branch open (active descendant). * @param string $children_id Nested list id for aria-controls. * @return string */ private static function render_category_item( $slug, $label, $url, $seo_urls, $active, $count, $inner = '', $has_children = false, $collapse_children = false, $force_open = false, $children_id = '' ) { $is_active = ( $slug === $active ); $is_collapsible = $has_children && $collapse_children; $context = self::context_attr( [ 'slug' => $slug, 'url' => $url, 'seoUrls' => (bool) $seo_urls, 'collapseChildren' => (bool) $is_collapsible, 'forceOpen' => (bool) $force_open, ] ); $classes = 'forwp-faq-categories__item'; if ( '' === $slug ) { $classes .= ' is-all'; } if ( $is_active ) { $classes .= ' is-active'; } if ( $has_children ) { $classes .= ' has-children'; } if ( $is_collapsible && $force_open ) { $classes .= ' is-expanded'; } $html = '
  • '; if ( null !== $count && ! $collapse_children ) { $link .= '' . esc_html( (string) $count ) . ''; } $link .= ''; $html .= $link; if ( $is_collapsible ) { $html .= ''; } if ( null !== $count && $collapse_children ) { $html .= '' . esc_html( (string) $count ) . ''; } $html .= $inner; $html .= '
  • '; return $html; } /** * Enqueue view script and hydrate Interactivity state once. */ private static function ensure_runtime() { static $done = false; if ( function_exists( 'wp_enqueue_script_module' ) ) { wp_enqueue_script_module( self::VIEW_SCRIPT ); } if ( $done || ! function_exists( 'wp_interactivity_state' ) ) { return; } $context = self::get_list_query_context(); $totals = self::count_totals( $context ); $done = true; wp_interactivity_state( self::STORE, [ 'category' => $context['activeSlug'], 'search' => '', 'visibleCount' => (int) ( $totals[ $context['activeSlug'] ] ?? 0 ), 'totals' => (object) $totals, 'openBranches' => (object) [], 'isNavActive' => static function () { $state = wp_interactivity_state( 'forwp/faq' ); $context = wp_interactivity_get_context( 'forwp/faq' ); $category = isset( $state['category'] ) ? (string) $state['category'] : ''; $slug = isset( $context['slug'] ) ? (string) $context['slug'] : ''; return $category === $slug; }, 'navAriaCurrent' => static function () { $state = wp_interactivity_state( 'forwp/faq' ); $context = wp_interactivity_get_context( 'forwp/faq' ); $category = isset( $state['category'] ) ? (string) $state['category'] : ''; $slug = isset( $context['slug'] ) ? (string) $context['slug'] : ''; return $category === $slug ? 'page' : null; }, 'isBranchOpen' => static function () { $context = wp_interactivity_get_context( 'forwp/faq' ); if ( empty( $context['collapseChildren'] ) ) { return true; } return ! empty( $context['forceOpen'] ); }, 'isBranchHidden' => static function () { $context = wp_interactivity_get_context( 'forwp/faq' ); if ( empty( $context['collapseChildren'] ) ) { return false; } return empty( $context['forceOpen'] ); }, 'isGroupVisible' => static function () { $state = wp_interactivity_state( 'forwp/faq' ); $context = wp_interactivity_get_context( 'forwp/faq' ); $category = isset( $state['category'] ) ? (string) $state['category'] : ''; if ( '' === $category ) { return true; } $slug = isset( $context['slug'] ) ? (string) $context['slug'] : ''; $ancestors = isset( $context['ancestors'] ) ? preg_split( '/\s+/', (string) $context['ancestors'], -1, PREG_SPLIT_NO_EMPTY ) : []; return $slug === $category || in_array( $category, $ancestors, true ); }, 'isItemVisible' => static function () { $state = wp_interactivity_state( 'forwp/faq' ); $context = wp_interactivity_get_context( 'forwp/faq' ); $category = isset( $state['category'] ) ? (string) $state['category'] : ''; $cats = isset( $context['cats'] ) ? preg_split( '/\s+/', (string) $context['cats'], -1, PREG_SPLIT_NO_EMPTY ) : []; if ( '' !== $category && ! in_array( $category, $cats, true ) ) { return false; } return true; }, ] ); } /** * @param array $context Context data. * @return string */ private static function context_attr( $context ) { if ( function_exists( 'wp_interactivity_data_wp_context' ) ) { return ' ' . wp_interactivity_data_wp_context( $context ); } return ' data-wp-context="' . esc_attr( wp_json_encode( $context ) ) . '"'; } }