Question answer… ` disclosure * blocks (the native WordPress "Details" block — an intentional FAQ/accordion). * 2. A heading (h2–h4) whose text ends in "?", with the sibling content up to the * next heading as its answer. * * Pure and WP-free: it takes HTML and returns pairs, so the caller renders the * content (do_blocks) and the schema layer decides whether there are enough pairs * to publish. The two-signal floor lives in the caller, not here. * * @package Agentimus */ namespace Agentimus; defined( 'ABSPATH' ) || exit; final class Faq { /** Max characters kept per answer — bounds the JSON-LD payload. */ const ANSWER_MAX = 1200; /** * Extract FAQ pairs from rendered HTML, in document order, de-duplicated by * question. Returns `[ ['q' => …, 'a' => …], … ]` (empty when nothing matches * or DOM parsing is unavailable). * * @param string $html Rendered post content. * @return array */ public static function extract( $html ) { $html = (string) $html; if ( '' === trim( $html ) || ! class_exists( '\DOMDocument' ) ) { return array(); } $dom = new \DOMDocument(); $prev = libxml_use_internal_errors( true ); // Swallow malformed-HTML warnings. // Wrap in a div + force UTF-8 so loadHTML doesn't mangle entities or hoist nodes. $dom->loadHTML( '
' . $html . '
', LIBXML_NONET ); libxml_clear_errors(); libxml_use_internal_errors( $prev ); $pairs = array(); // 1. Disclosure blocks:
Q A
. foreach ( iterator_to_array( $dom->getElementsByTagName( 'details' ) ) as $details ) { $summary = null; foreach ( $details->childNodes as $child ) { if ( XML_ELEMENT_NODE === $child->nodeType && 'summary' === strtolower( $child->nodeName ) ) { $summary = $child; break; } } if ( null === $summary ) { continue; } $q = self::norm( $summary->textContent ); $a = ''; foreach ( $details->childNodes as $child ) { if ( $child !== $summary ) { $a .= ' ' . $child->textContent; } } self::push( $pairs, $q, $a ); } // 2. Heading-question → following sibling content. Walk the wrapper's direct // children in order: open a question on an h2–h4 ending in "?", and gather // everything until the next heading as its answer. Skip
here so // a preceding heading-question doesn't absorb a disclosure block's text. $root = $dom->getElementById( 'agentimus-faq-root' ); if ( $root ) { $open = ''; $buffer = ''; foreach ( $root->childNodes as $node ) { $is_el = XML_ELEMENT_NODE === $node->nodeType; if ( $is_el && preg_match( '/^h[2-4]$/i', $node->nodeName ) ) { self::push( $pairs, $open, $buffer ); $text = self::norm( $node->textContent ); $open = self::is_question( $text ) ? $text : ''; $buffer = ''; continue; } if ( $is_el && 'details' === strtolower( $node->nodeName ) ) { continue; } if ( '' !== $open ) { $buffer .= ' ' . $node->textContent; } } self::push( $pairs, $open, $buffer ); } return array_values( $pairs ); } /** * Add a pair if both sides are non-empty and the question is new (case-insensitive), * clipping the answer. Keyed by normalized question so the two signals don't double up. * * @param array $pairs Accumulator, keyed by lowercased question. * @param string $q Question text. * @param string $a Raw answer text. */ private static function push( array &$pairs, $q, $a ) { $q = self::norm( $q ); $a = self::clip( self::norm( $a ) ); if ( '' === $q || '' === $a ) { return; } $key = strtolower( $q ); if ( ! isset( $pairs[ $key ] ) ) { $pairs[ $key ] = array( 'q' => $q, 'a' => $a ); } } /** * Whether a heading reads as a question: ends in "?" and is short enough to be * one (a long paragraph that happens to end in "?" is prose, not an FAQ heading). * * @param string $text Heading text. * @return bool */ private static function is_question( $text ) { return '' !== $text && '?' === substr( $text, -1 ) && mb_strlen( $text ) <= 200; } /** * Collapse whitespace and trim — textContent already strips tags. * * @param string $s Raw text. * @return string */ private static function norm( $s ) { return trim( (string) preg_replace( '/\s+/', ' ', (string) $s ) ); } /** * Bound an answer to ANSWER_MAX characters at a sensible cut. * * @param string $s Normalized answer. * @return string */ private static function clip( $s ) { if ( mb_strlen( $s ) <= self::ANSWER_MAX ) { return $s; } return rtrim( mb_substr( $s, 0, self::ANSWER_MAX ) ) . '…'; } }