From 068060708c7abd13f43b74811125b19303bc76f6 Mon Sep 17 00:00:00 2001 From: Sander van Dragt Date: Fri, 28 Aug 2026 16:51:51 +0100 Subject: [PATCH] feat: emit FAQPage schema from accordion blocks (#1231) Adds FAQPage structured data for pages built from shiro/accordion blocks. The blocks are static, so there is no render callback to hook; instead an FAQPage node is appended to Yoast's schema @graph via the wpseo_schema_graph filter, reading the saved question/answer markup with DOMDocument (sourced block attributes such as the accordion-item title are not available to parse_blocks() server-side). Gated on the presence of a shiro/accordion block, so it follows the content wherever editors add accordions. Google retired FAQ rich results in 2023, so this serves GEO / AI-citation structured data rather than a search rich snippet. Co-Authored-By: Claude Opus 4.8 --- functions.php | 2 + inc/editor/blocks/accordion.php | 157 ++++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 inc/editor/blocks/accordion.php diff --git a/functions.php b/functions.php index ee1a8f522..0fffc3918 100755 --- a/functions.php +++ b/functions.php @@ -258,6 +258,7 @@ function wmf_admin_scripts() { * Block editor functionality. */ require get_template_directory() . '/inc/editor/namespace.php'; +require get_template_directory() . '/inc/editor/blocks/accordion.php'; require get_template_directory() . '/inc/editor/blocks/blog-post.php'; require get_template_directory() . '/inc/editor/blocks/donation-portal-form.php'; require get_template_directory() . '/inc/editor/blocks/double-heading.php'; @@ -291,6 +292,7 @@ function wmf_admin_scripts() { WMF\Assets\bootstrap(); WMF\Editor\bootstrap(); WMF\Editor\HasBlockColumn\bootstrap(); +WMF\Editor\Blocks\Accordion\bootstrap(); WMF\Editor\Blocks\BlogPost\bootstrap(); WMF\Editor\Blocks\InlineLanguages\bootstrap(); WMF\Editor\Blocks\DonationPortalForm\bootstrap(); diff --git a/inc/editor/blocks/accordion.php b/inc/editor/blocks/accordion.php new file mode 100644 index 000000000..dd39f024f --- /dev/null +++ b/inc/editor/blocks/accordion.php @@ -0,0 +1,157 @@ +post_content ?? '' ); + if ( empty( $questions ) ) { + return $graph; + } + + $canonical = ''; + if ( is_object( $context ) && ! empty( $context->canonical ) ) { + $canonical = $context->canonical; + } + if ( empty( $canonical ) ) { + $canonical = (string) get_permalink( $post ); + } + + $graph[] = [ + '@type' => 'FAQPage', + '@id' => $canonical . '#faq', + 'mainEntity' => array_map( + function ( $item ) { + return [ + '@type' => 'Question', + 'name' => $item['name'], + 'acceptedAnswer' => [ + '@type' => 'Answer', + 'text' => $item['answer'], + ], + ]; + }, + $questions + ), + ]; + + // Link the FAQPage to the page's WebPage node so it sits in the graph rather + // than floating; Yoast ids its WebPage node as #webpage. + $graph[ array_key_last( $graph ) ]['mainEntityOfPage'] = [ '@id' => $canonical . '#webpage' ]; + + return $graph; +} + +/** + * Extract question/answer pairs from saved accordion markup. + * + * Reads the static block HTML directly (mirroring inc/editor/blocks/toc.php), + * since sourced block attributes such as the accordion-item title are not + * available to parse_blocks() on the server. + * + * @param string $content Raw post content. + * @return array List of [ 'name' => string, 'answer' => string ] pairs. + */ +function get_faq_items_from_content( string $content ): array { + if ( trim( $content ) === '' ) { + return []; + } + + $doc = new DOMDocument(); + + // Suppress libxml warnings for the theme's HTML fragments; see toc.php / #907. + libxml_use_internal_errors( true ); + $doc->loadHTML( '' . $content, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); + libxml_clear_errors(); + + $xpath = new DOMXPath( $doc ); + + // Match the exact `accordion-item` class token (not accordion-item__title etc.). + $items = $xpath->query( "//*[contains(concat(' ', normalize-space(@class), ' '), ' accordion-item ')]" ); + + $faq = []; + if ( ! $items ) { + return $faq; + } + + /* phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase */ + foreach ( $items as $item ) { + $title_nodes = $xpath->query( ".//*[contains(concat(' ', normalize-space(@class), ' '), ' accordion-item__title-text ')]", $item ); + $answer_nodes = $xpath->query( ".//*[contains(concat(' ', normalize-space(@class), ' '), ' accordion-item__content ')]", $item ); + + if ( ! $title_nodes || ! $title_nodes->length || ! $answer_nodes || ! $answer_nodes->length ) { + continue; + } + + $name = trim( preg_replace( '/\s+/', ' ', $title_nodes->item( 0 )->textContent ) ); + $answer = trim( inner_html( $answer_nodes->item( 0 ) ) ); + + if ( $name === '' || $answer === '' ) { + continue; + } + + $faq[] = [ + 'name' => $name, + 'answer' => $answer, + ]; + } + /* phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase */ + + return $faq; +} + +/** + * Serialise the inner HTML of a DOM node (its children, not the node itself). + * + * @param DOMNode $node Node whose children to serialise. + * @return string Inner HTML. + */ +function inner_html( DOMNode $node ): string { + $html = ''; + foreach ( $node->childNodes as $child ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + $html .= $node->ownerDocument->saveHTML( $child ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + } + return $html; +}