, no JS, with FAQPage JSON-LD (#662).
*
* The markup and CSS classes are identical to the block's, so all four builders
* render the same accordion from the same stylesheet.
*
* Schema goes through Schema_Graph, not straight to the page. The graph collects
* this module's questions from Beaver Builder's stored layout during `wp_head` —
* see `Schema_Graph::collect_beaver_faq()` — and merges them into the request's
* one FAQPage. By the time this renders, that has already happened, so emitting
* here as well would recreate the duplicate the graph exists to prevent (#355).
* The inline block below is the fallback for requests where no graph is rendered.
*
* The class is deliberately in the global namespace with a `ThinkRank_` prefix
* rather than under `ThinkRank\Editor\Beaver`: Beaver Builder resolves modules
* by class-name string, derives paths from a `ReflectionClass` over them, and
* every module in its own tree and its ecosystem is global. Following the host's
* convention is worth more here than namespace tidiness.
*
* @package ThinkRank
* @subpackage Editor\Beaver
* @since 2.5.0
*/
declare(strict_types=1);
// Prevent direct access
if (!defined('ABSPATH')) {
exit;
}
/**
* FAQ Module.
*
* @since 2.5.0
*/
class ThinkRank_Beaver_FAQ_Module extends FLBuilderModule {
/**
* The module slug, and the settings `type` its stored nodes carry.
*
* Shared with `Schema_Graph`, which matches stored layout nodes on it.
*/
public const SLUG = 'thinkrank-faq';
/**
* Constructor.
*/
public function __construct() {
parent::__construct([
'name' => __('FAQ (ThinkRank)', 'thinkrank'),
'description' => __('An accordion of questions and answers that emits FAQPage schema.', 'thinkrank'),
'category' => __('ThinkRank', 'thinkrank'),
'slug' => self::SLUG,
// `dir`/`url` are passed explicitly. Beaver Builder can infer them
// from ABSPATH, but that inference breaks when the plugin is
// symlinked or WordPress is in a subdirectory, and the module would
// then look for its frontend template under a path that does not
// exist.
'dir' => THINKRANK_PLUGIN_DIR . 'includes/editor/beaver/faq/',
'url' => THINKRANK_PLUGIN_URL . 'includes/editor/beaver/faq/',
// Re-render over AJAX while typing instead of reloading the page.
'partial_refresh' => true,
]);
}
/**
* Render the accordion.
*
* Called from `includes/frontend.php`, which is the only entry point
* Beaver Builder offers.
*
* @since 2.5.0
* @param object|array $settings Module settings.
* @return void
*/
public function render_content($settings): void {
$settings = self::to_array($settings);
$items = self::usable_items($settings);
if (empty($items)) {
// A module with nothing in it still needs a box in the builder, or
// the author cannot select what they just dropped on the canvas.
if (self::in_builder()) {
echo '
' . esc_html__('Add a question to get started.', 'thinkrank') . '
';
}
return;
}
wp_enqueue_style('thinkrank-faq-block');
$heading_tag = self::heading_tag($settings);
$first_open = !empty($settings['first_open']);
$output = '';
$heading = trim((string) ($settings['heading'] ?? ''));
if ('' !== $heading) {
$output .= sprintf(
'<%1$s class="thinkrank-faq__heading">%2$s%1$s>',
esc_html($heading_tag),
esc_html($heading)
);
}
foreach ($items as $index => $faq) {
$output .= '
';
$output .= ''
. esc_html((string) ($faq['question'] ?? ''))
. '
';
$output .= ''
. wp_kses_post((string) ($faq['answer'] ?? ''))
. '
';
$output .= ' ';
}
$output .= '
';
echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
$this->maybe_render_schema($settings);
}
/**
* Normalize Beaver Builder settings to an array.
*
* Settings arrive as a stdClass, and repeater rows inside them are objects
* too, so a plain `(array)` cast leaves the rows untouched. Everything
* downstream — including `Schema_Graph`, which reads the same rows straight
* out of postmeta — wants arrays all the way down.
*
* @since 2.5.0
* @param mixed $settings Raw settings.
* @return array
*/
public static function to_array($settings): array {
if (is_object($settings)) {
$settings = get_object_vars($settings);
}
if (!is_array($settings)) {
return [];
}
foreach ($settings as $key => $value) {
if (is_object($value) || is_array($value)) {
$settings[$key] = json_decode((string) wp_json_encode($value), true) ?? [];
}
}
return $settings;
}
/**
* The repeater rows worth rendering.
*
* Public so `Schema_Graph` reads the module exactly as it renders — one
* definition of "a usable FAQ item" for both the page and its schema.
*
* @since 2.5.0
* @param array $settings Module settings.
* @return array
*/
public static function usable_items(array $settings): array {
$faqs = is_array($settings['faqs'] ?? null) ? $settings['faqs'] : [];
return array_values(array_filter($faqs, static function ($faq) {
return is_array($faq) && (!empty($faq['question']) || !empty($faq['answer']));
}));
}
/**
* FAQPage question entities from this module's settings.
*
* Shared with the graph's collection pass so a question absorbed there and
* one printed here can never disagree.
*
* @since 2.5.0
* @param array $settings Module settings.
* @return array
*/
public static function question_entities(array $settings): array {
$entities = [];
foreach (self::usable_items($settings) as $faq) {
$question = trim(wp_strip_all_tags((string) ($faq['question'] ?? '')));
$answer = trim((string) ($faq['answer'] ?? ''));
if ('' === $question || '' === $answer) {
continue;
}
$entities[] = [
'@type' => 'Question',
'name' => $question,
'acceptedAnswer' => [
'@type' => 'Answer',
'text' => wp_kses_post($answer),
],
];
}
return $entities;
}
/**
* Whether a module's settings arm its FAQ schema.
*
* Beaver Builder writes an unchecked checkbox as the string `'0'`, not an
* absent key, so `empty()` is the correct test and a cleared toggle really
* does clear. This differs from Bricks, where clearing drops the key
* entirely — the same-looking check has a different reason behind it in
* each builder.
*
* @since 2.5.0
* @param array $settings Module settings.
* @return bool
*/
public static function schema_enabled(array $settings): bool {
return !empty($settings['output_schema']);
}
/**
* Whether Beaver Builder's editor is rendering this.
*
* Covers both the builder page itself and the AJAX partial refreshes it
* fires while the author types.
*
* @since 2.5.0
* @return bool
*/
private static function in_builder(): bool {
return class_exists('\\FLBuilderModel') && (bool) \FLBuilderModel::is_builder_active();
}
/**
* Emit FAQPage JSON-LD, unless somebody else already has.
*
* @param array $settings Module settings.
* @return void
*/
private function maybe_render_schema(array $settings): void {
if (!self::schema_enabled($settings)) {
return;
}
// Never in the builder canvas: the author is looking at a preview, and
// Beaver Builder re-renders modules over AJAX as they type.
if (self::in_builder()) {
return;
}
// The graph collected this module's questions during wp_head and owns
// the page's single FAQPage now (#355). Either way this module stays
// quiet.
if (class_exists('ThinkRank\\Frontend\\Schema_Graph')
&& \ThinkRank\Frontend\Schema_Graph::instance()->absorbed_content_faq()
) {
return;
}
$entities = self::question_entities($settings);
if (empty($entities)) {
return;
}
$json = wp_json_encode([
'@context' => 'https://schema.org',
'@type' => 'FAQPage',
'mainEntity' => $entities,
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
if (false !== $json) {
echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
}
/**
* A heading tag from the allowed set.
*
* @param array $settings Module settings.
* @return string
*/
private static function heading_tag(array $settings): string {
$tag = (string) ($settings['heading_tag'] ?? 'h2');
return in_array($tag, ['h2', 'h3', 'h4', 'p'], true) ? $tag : 'h2';
}
}
/**
* The repeater row form. Registered separately because Beaver Builder resolves
* a `type => form` field by id, unlike Elementor's and Bricks' inline repeaters.
*/
FLBuilder::register_settings_form('thinkrank_faq_item_form', [
'title' => __('Question', 'thinkrank'),
'tabs' => [
'general' => [
'title' => __('General', 'thinkrank'),
'sections' => [
'general' => [
'title' => '',
'fields' => [
'question' => [
'type' => 'text',
'label' => __('Question', 'thinkrank'),
'connections' => ['string'],
],
'answer' => [
'type' => 'editor',
'label' => __('Answer', 'thinkrank'),
'media_buttons' => false,
'connections' => ['string'],
],
],
],
],
],
],
]);
FLBuilder::register_module('ThinkRank_Beaver_FAQ_Module', [
'general' => [
'title' => __('Questions', 'thinkrank'),
'sections' => [
'content' => [
'title' => '',
'fields' => [
'heading' => [
'type' => 'text',
'label' => __('Section heading', 'thinkrank'),
'default' => __('Frequently asked questions', 'thinkrank'),
'connections' => ['string'],
],
'heading_tag' => [
'type' => 'select',
'label' => __('Heading tag', 'thinkrank'),
'default' => 'h2',
'options' => [
'h2' => 'H2',
'h3' => 'H3',
'h4' => 'H4',
'p' => __('Paragraph', 'thinkrank'),
],
],
'faqs' => [
'type' => 'form',
'label' => __('Question', 'thinkrank'),
'form' => 'thinkrank_faq_item_form',
'preview_text' => 'question',
'multiple' => true,
],
'first_open' => [
'type' => 'select',
'label' => __('Open first item by default', 'thinkrank'),
'default' => '1',
'options' => [
'1' => __('Yes', 'thinkrank'),
'0' => __('No', 'thinkrank'),
],
],
],
],
],
],
'schema' => [
'title' => __('Schema', 'thinkrank'),
'sections' => [
'schema' => [
'title' => '',
'fields' => [
'output_schema' => [
'type' => 'select',
'label' => __('Output FAQ schema (JSON-LD)', 'thinkrank'),
'default' => '1',
'options' => [
'1' => __('Yes', 'thinkrank'),
'0' => __('No', 'thinkrank'),
],
'help' => __('Adds FAQPage structured data for rich results.', 'thinkrank'),
],
],
],
],
],
]);