[
'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(
'',
$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 ''
. esc_html__( 'No questions found. Try another search.', '4wp-faq' )
. '
';
}
/**
* 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 '' . $content . '
';
}
/**
* @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 = '
';
}
$html .= '';
$html .= '';
if ( $show_all ) {
$all_url = Faq_Filter::get_nav_url( '', $seo_urls );
$html .= self::render_category_item( '', $all_label, $all_url, $seo_urls, $active, null );
}
$html .= self::render_category_nodes( $terms, $seo_urls, $active, $show_count, $collapse_children );
$html .= '
';
if ( empty( $terms ) && ! $show_all ) {
$html .= '' . esc_html__( 'No FAQ categories yet.', '4wp-faq' ) . '
';
}
$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 = '