` region, first qualifying * region only. * 2. **Explicit FAQ.** Every valid `` region, in * document order, contributing to one FAQPage. * 3. **Automatic HowTo.** A procedure heading outside every valid region whose * next meaningful sibling is an ordered list. * 4. **Automatic FAQ.** Question headings and `details > summary` outside every * valid region. * * A node claimed by an earlier category cannot supply a later one, which is * enforced by claiming elements rather than by reasoning about ranges. The case * that makes this necessary: "How to bake bread" begins with `how`, so it reads * as a question under the FAQ rules as well as a procedure heading under the * HowTo rules. Automatic HowTo detection therefore always runs and always * claims, even when an explicit region has already supplied the HowTo object and * the automatic result will be discarded - otherwise the heading and its steps * would reappear as FAQ content. * * Explicit beats automatic within a type as well as across types: a post with a * valid FAQ region gets a FAQPage built from that region alone. Mixing a marked * region with heuristic matches elsewhere on the same page would make the output * depend on wording the editor did not intend to be read that way. * * Nothing here renders or executes content. It reads a parsed DOM and produces * normalised text. * * @since 1.3.0 */ final class SchemaExtractor { /** * Largest number of FAQ pairs in one FAQPage object. * * @since 1.3.0 * @var int */ public const MAX_FAQ_PAIRS = 20; /** * Largest number of steps in one HowTo object. * * @since 1.3.0 * @var int */ public const MAX_HOWTO_STEPS = 20; /** * Character limit for a question or a step name. * * @since 1.3.0 * @var int */ public const MAX_QUESTION_CHARS = 200; /** * Character limit for an answer or a step body. * * @since 1.3.0 * @var int */ public const MAX_ANSWER_CHARS = 2000; /** * Character budget across one generated schema object. * * @since 1.3.0 * @var int */ public const MAX_SCHEMA_CHARS = 40000; /** * Smallest number of deduplicated pairs an automatic FAQ needs. * * @since 1.3.0 * @var int */ public const MIN_AUTOMATIC_FAQ_PAIRS = 2; /** * Smallest number of steps any HowTo needs. * * @since 1.3.0 * @var int */ public const MIN_HOWTO_STEPS = 2; /** * Extracts the FAQ pairs and the HowTo steps a prepared source supports. * * @since 1.3.0 * * @param array{source: string, outside: string, regions: array, bounded: bool} $prepared Prepared source from SchemaSource. * @param string $post_title Normalised post title, used as a HowTo name fallback. * @return array{faq: array, faq_source: string, howto: array{name: string, steps: array}|null, howto_source: string, diagnostics: array} Extracted candidates. */ public static function extract( array $prepared, $post_title ) { $diagnostics = array(); if ( empty( $prepared['bounded'] ) ) { return self::nothing( $diagnostics ); } $explicit_howto = null; $explicit_faq = array(); foreach ( $prepared['regions'] as $region ) { $parsed = SchemaSource::parse( SchemaSource::strip_markers( $region['html'] ) ); if ( $parsed['recovered'] ) { $diagnostics[ SchemaSource::DIAGNOSTIC_PARSE_RECOVERED ] = SchemaSource::DIAGNOSTIC_PARSE_RECOVERED; } if ( null === $parsed['document'] ) { $diagnostics[ SchemaSource::DIAGNOSTIC_PARSE_FAILED ] = SchemaSource::DIAGNOSTIC_PARSE_FAILED; continue; } if ( SchemaSource::REGION_HOWTO === $region['type'] ) { if ( null === $explicit_howto ) { $explicit_howto = self::explicit_howto( $parsed['document'], $post_title ); } continue; } $claimed = array(); $explicit_faq = array_merge( $explicit_faq, self::faq_pairs( $parsed['document'], true, $claimed ) ); } $automatic_howto = null; $automatic_faq = array(); $outside = SchemaSource::parse( $prepared['outside'] ); if ( $outside['recovered'] ) { $diagnostics[ SchemaSource::DIAGNOSTIC_PARSE_RECOVERED ] = SchemaSource::DIAGNOSTIC_PARSE_RECOVERED; } if ( null === $outside['document'] && '' !== trim( (string) $prepared['outside'] ) ) { $diagnostics[ SchemaSource::DIAGNOSTIC_PARSE_FAILED ] = SchemaSource::DIAGNOSTIC_PARSE_FAILED; } if ( null !== $outside['document'] ) { $claimed = array(); /* * Always detected, even when an explicit region already supplied the * HowTo: the claim is what keeps a procedure heading and its ordered * list out of the automatic FAQ. */ $automatic_howto = self::automatic_howto( $outside['document'], $claimed ); $automatic_faq = self::faq_pairs( $outside['document'], false, $claimed ); } $faq = array(); $faq_source = 'none'; if ( array() !== $explicit_faq ) { $faq = self::bound_pairs( $explicit_faq ); $faq_source = 'explicit-markers'; } elseif ( count( self::deduplicate( $automatic_faq ) ) >= self::MIN_AUTOMATIC_FAQ_PAIRS ) { $faq = self::bound_pairs( $automatic_faq ); $faq_source = 'automatic-detection'; } if ( count( $faq ) < 1 ) { $faq = array(); $faq_source = 'none'; } $howto = null; $howto_source = 'none'; if ( null !== $explicit_howto ) { $howto = $explicit_howto; $howto_source = 'explicit-markers'; } elseif ( null !== $automatic_howto ) { $howto = $automatic_howto; $howto_source = 'automatic-detection'; } return array( 'faq' => $faq, 'faq_source' => $faq_source, 'howto' => $howto, 'howto_source' => $howto_source, 'diagnostics' => array_values( $diagnostics ), ); } /** * Returns the empty extraction result. * * @since 1.3.0 * * @param array $diagnostics Diagnostics collected so far. * @return array{faq: array, faq_source: string, howto: null, howto_source: string, diagnostics: array} Empty result. */ private static function nothing( array $diagnostics ) { return array( 'faq' => array(), 'faq_source' => 'none', 'howto' => null, 'howto_source' => 'none', 'diagnostics' => array_values( $diagnostics ), ); } /** * Extracts the FAQ pairs of one parsed document. * * Question candidates are collected in document order first and the * `details` elements among them are claimed, then answers are read with * claimed subtrees excluded. Two passes rather than one, because a `details` * sitting inside a heading's section is both a question of its own and part * of that heading's answer, and reading the answer first would publish the * same text twice. * * @since 1.3.0 * * @param DOMDocument $document Parsed fragment. * @param bool $explicit True inside a valid marker region, where wording is not tested. * @param array $claimed Claimed elements, by reference. * @return array Pairs in document order. */ private static function faq_pairs( DOMDocument $document, $explicit, array &$claimed ) { $candidates = array(); foreach ( self::elements( $document ) as $element ) { $tag = strtolower( $element->tagName ); if ( 'summary' === $tag ) { $details = $element->parentNode; if ( ! $details instanceof DOMElement || 'details' !== strtolower( $details->tagName ) ) { continue; } if ( self::is_claimed( $element, $claimed ) ) { continue; } $question = SchemaText::node_text( $element ); if ( '' === $question || ( ! $explicit && ! SchemaText::is_question( $question ) ) ) { continue; } $candidates[] = array( 'kind' => 'details', 'node' => $element, 'details' => $details, 'text' => $question, ); /* * Claimed now so that an enclosing heading's answer does not * repeat this question and its answer as prose. */ $claimed[ spl_object_id( $details ) ] = $details; continue; } if ( 1 !== preg_match( '/^h([1-6])$/', $tag, $matches ) ) { continue; } $level = (int) $matches[1]; if ( $explicit ) { if ( $level < 2 ) { continue; } } elseif ( 2 !== $level && 3 !== $level ) { continue; } if ( self::is_claimed( $element, $claimed ) ) { continue; } $question = SchemaText::node_text( $element ); if ( '' === $question || ( ! $explicit && ! SchemaText::is_question( $question ) ) ) { continue; } $candidates[] = array( 'kind' => 'heading', 'node' => $element, 'level' => $level, 'text' => $question, ); } $pairs = array(); foreach ( $candidates as $candidate ) { $answer = 'details' === $candidate['kind'] ? self::details_body( $candidate['details'], $candidate['node'], $claimed ) : self::section_text( $candidate['node'], $claimed ); if ( '' === $answer ) { continue; } $pairs[] = array( 'question' => $candidate['text'], 'answer' => $answer, ); } return $pairs; } /** * Returns the text of a `details` element excluding its summary. * * @since 1.3.0 * * @param DOMElement $details Details element. * @param DOMElement $summary Its summary element. * @param array $claimed Claimed elements. * @return string Normalised answer text. */ private static function details_body( DOMElement $details, DOMElement $summary, array $claimed ) { $skip = $claimed; $skip[ spl_object_id( $summary ) ] = $summary; // The details element itself is claimed; reading it must not skip it. unset( $skip[ spl_object_id( $details ) ] ); return SchemaText::normalize( self::text_excluding( $details, $skip ) ); } /** * Returns the text of a heading's section, excluding claimed subtrees. * * A section runs from just after the heading to the next heading at the same * or a higher level, which is how a reader scopes the answer belonging to a * question heading. * * @since 1.3.0 * * @param DOMElement $heading Heading that opens the section. * @param array $claimed Claimed elements. * @return string Normalised answer text. */ private static function section_text( DOMElement $heading, array $claimed ) { $level = self::heading_level( $heading ); $text = ''; for ( $node = $heading->nextSibling; null !== $node; $node = $node->nextSibling ) { if ( $node instanceof DOMElement ) { $node_level = self::heading_level( $node ); if ( $node_level > 0 && $node_level <= $level ) { break; } } $text .= ' ' . self::text_excluding( $node, $claimed ); } return SchemaText::normalize( $text ); } /** * Returns a node's text with claimed subtrees omitted. * * @since 1.3.0 * * @param DOMNode $node Node to read. * @param array $claimed Claimed elements. * @return string Raw concatenated text. */ private static function text_excluding( DOMNode $node, array $claimed ) { if ( $node instanceof DOMElement && isset( $claimed[ spl_object_id( $node ) ] ) ) { return ''; } if ( ! $node->hasChildNodes() ) { return (string) $node->textContent; } $text = ''; foreach ( $node->childNodes as $child ) { $text .= ' ' . self::text_excluding( $child, $claimed ); } return $text; } /** * Builds the HowTo of one explicit region, or null when it does not qualify. * * @since 1.3.0 * * @param DOMDocument $document Parsed region. * @param string $post_title Normalised post title, used when the region has no heading. * @return array{name: string, steps: array}|null HowTo candidate. */ private static function explicit_howto( DOMDocument $document, $post_title ) { $name = ''; foreach ( self::elements( $document ) as $element ) { if ( self::heading_level( $element ) > 0 ) { $name = SchemaText::node_text( $element ); if ( '' !== $name ) { break; } } } if ( '' === $name ) { $name = SchemaText::normalize( $post_title ); } if ( '' === $name ) { return null; } $steps = array(); foreach ( $document->getElementsByTagName( 'ol' ) as $list ) { $items = self::list_items( $list ); if ( count( $items ) >= self::MIN_HOWTO_STEPS ) { foreach ( $items as $item ) { $steps[] = array( 'name' => '', 'text' => $item, ); } break; } } if ( array() === $steps ) { $steps = self::heading_steps( $document ); } if ( count( $steps ) < self::MIN_HOWTO_STEPS ) { return null; } return array( 'name' => $name, 'steps' => $steps, ); } /** * Builds the HowTo of a procedure heading outside every marker region. * * Claims the heading and its ordered list whenever one qualifies, so that * neither can supply an automatic FAQ pair afterwards. * * @since 1.3.0 * * @param DOMDocument $document Parsed source outside valid regions. * @param array $claimed Claimed elements, by reference. * @return array{name: string, steps: array}|null HowTo candidate. */ private static function automatic_howto( DOMDocument $document, array &$claimed ) { $howto = null; foreach ( self::elements( $document ) as $element ) { $level = self::heading_level( $element ); if ( 2 !== $level && 3 !== $level ) { continue; } $name = SchemaText::node_text( $element ); if ( '' === $name || ! SchemaText::is_howto_heading( $name ) ) { continue; } $list = self::next_meaningful_sibling( $element ); if ( ! $list instanceof DOMElement || 'ol' !== strtolower( $list->tagName ) ) { continue; } $items = self::list_items( $list ); if ( count( $items ) < self::MIN_HOWTO_STEPS ) { continue; } $claimed[ spl_object_id( $element ) ] = $element; $claimed[ spl_object_id( $list ) ] = $list; if ( null !== $howto ) { // First qualifying region only; later ones are still claimed. continue; } $steps = array(); foreach ( $items as $item ) { $steps[] = array( 'name' => '', 'text' => $item, ); } $howto = array( 'name' => $name, 'steps' => $steps, ); } return $howto; } /** * Returns one step per heading section that carries text. * * The fallback inside an explicit HowTo region for content written as a * sequence of headed sections rather than as an ordered list. A section with * no body of its own still makes a usable step from its heading, which is * the common shape of a short procedure. * * @since 1.3.0 * * @param DOMDocument $document Parsed region. * @return array Steps in document order. */ private static function heading_steps( DOMDocument $document ) { $steps = array(); $empty = array(); foreach ( self::elements( $document ) as $element ) { $level = self::heading_level( $element ); if ( $level < 2 ) { continue; } $name = SchemaText::node_text( $element ); if ( '' === $name ) { continue; } $body = self::section_text( $element, $empty ); $steps[] = array( 'name' => $name, 'text' => '' === $body ? $name : $body, ); } return $steps; } /** * Returns the normalised text of an ordered list's direct items. * * Direct children only: a nested list belongs to its own parent item and its * items are not steps of the outer procedure. * * @since 1.3.0 * * @param DOMElement $ordered Ordered list element. * @return array Non-empty item texts in document order. */ private static function list_items( DOMElement $ordered ) { $items = array(); foreach ( $ordered->childNodes as $child ) { if ( ! $child instanceof DOMElement || 'li' !== strtolower( $child->tagName ) ) { continue; } $text = SchemaText::node_text( $child ); if ( '' !== $text ) { $items[] = $text; } } return $items; } /** * Returns the next sibling that is not whitespace or a comment. * * @since 1.3.0 * * @param DOMNode $node Starting node. * @return DOMNode|null Next meaningful sibling, or null. */ private static function next_meaningful_sibling( DOMNode $node ) { for ( $sibling = $node->nextSibling; null !== $sibling; $sibling = $sibling->nextSibling ) { if ( XML_COMMENT_NODE === $sibling->nodeType ) { continue; } if ( XML_TEXT_NODE === $sibling->nodeType && '' === SchemaText::normalize( $sibling->textContent ) ) { continue; } return $sibling; } return null; } /** * Returns every element of a document in document order. * * A single wildcard query walks the tree once and yields elements in * document order, which querying per tag name would not. * * @since 1.3.0 * * @param DOMDocument $document Parsed fragment. * @return array Elements in document order. */ private static function elements( DOMDocument $document ) { $elements = array(); foreach ( $document->getElementsByTagName( '*' ) as $element ) { if ( $element instanceof DOMElement ) { $elements[] = $element; } } return $elements; } /** * Returns the heading level of an element, or 0 when it is not a heading. * * @since 1.3.0 * * @param DOMNode $node Candidate node. * @return int Level 1-6, or 0. */ private static function heading_level( DOMNode $node ) { if ( ! $node instanceof DOMElement ) { return 0; } if ( 1 !== preg_match( '/^h([1-6])$/i', $node->tagName, $matches ) ) { return 0; } return (int) $matches[1]; } /** * Reports whether a node or one of its ancestors has been claimed. * * The claim map holds the node, not a flag, and that is load-bearing rather * than wasteful. PHP's DOM extension creates the object wrapper for a node * on demand and keeps it only while something references it, so a map of * bare `spl_object_id()` values goes stale the moment the traversal that * produced them ends - and a later traversal of the same tree gets fresh * ids. Keeping the node alive keeps its identity, which is what makes a * claim made during HowTo detection still recognisable during the FAQ pass. * * @since 1.3.0 * * @param DOMNode $node Node to test. * @param array $claimed Claimed elements. * @return bool True when the node is inside a claimed subtree. */ private static function is_claimed( DOMNode $node, array $claimed ) { if ( array() === $claimed ) { return false; } for ( $current = $node; null !== $current; $current = $current->parentNode ) { if ( isset( $claimed[ spl_object_id( $current ) ] ) ) { return true; } } return false; } /** * Applies the per-item limits, the deduplication rule and the budget. * * Deduplication happens before the budget is spent, so a repeated question * cannot consume the allowance a distinct one needed - and, as the contract * requires, cannot be used to reach the automatic threshold either. * * @since 1.3.0 * * @param array $pairs Raw pairs in document order. * @return array Bounded pairs. */ private static function bound_pairs( array $pairs ) { $bounded = array(); $budget = self::MAX_SCHEMA_CHARS; foreach ( self::deduplicate( $pairs ) as $pair ) { if ( count( $bounded ) >= self::MAX_FAQ_PAIRS ) { break; } $question = SchemaText::truncate( $pair['question'], self::MAX_QUESTION_CHARS ); $answer = SchemaText::truncate( $pair['answer'], self::MAX_ANSWER_CHARS ); if ( '' === $question || '' === $answer ) { continue; } $cost = mb_strlen( $question, 'UTF-8' ) + mb_strlen( $answer, 'UTF-8' ); if ( $cost > $budget ) { break; } $budget -= $cost; $bounded[] = array( 'question' => $question, 'answer' => $answer, ); } return $bounded; } /** * Removes repeated questions, keeping the first in source order. * * Case-insensitive after whitespace normalisation, which is what makes * "What is AEO?" and "what is aeo?" one question rather than two. * * @since 1.3.0 * * @param array $pairs Raw pairs in document order. * @return array Pairs with duplicates removed. */ private static function deduplicate( array $pairs ) { $seen = array(); $unique = array(); foreach ( $pairs as $pair ) { $key = mb_strtolower( SchemaText::normalize( $pair['question'] ), 'UTF-8' ); if ( '' === $key || isset( $seen[ $key ] ) ) { continue; } $seen[ $key ] = true; $unique[] = $pair; } return $unique; } /** * Applies the per-step limits and the budget to a HowTo candidate. * * @since 1.3.0 * * @param array{name: string, steps: array} $howto HowTo candidate. * @return array{name: string, steps: array}|null Bounded candidate, or null when it no longer qualifies. */ public static function bound_howto( array $howto ) { $name = SchemaText::truncate( SchemaText::normalize( $howto['name'] ), self::MAX_QUESTION_CHARS ); if ( '' === $name ) { return null; } $budget = self::MAX_SCHEMA_CHARS - mb_strlen( $name, 'UTF-8' ); $steps = array(); foreach ( $howto['steps'] as $step ) { if ( count( $steps ) >= self::MAX_HOWTO_STEPS ) { break; } $text = SchemaText::truncate( SchemaText::normalize( $step['text'] ), self::MAX_ANSWER_CHARS ); $label = SchemaText::truncate( SchemaText::normalize( $step['name'] ), self::MAX_QUESTION_CHARS ); if ( '' === $text ) { continue; } $cost = mb_strlen( $text, 'UTF-8' ) + mb_strlen( $label, 'UTF-8' ); if ( $cost > $budget ) { break; } $budget -= $cost; $steps[] = array( 'name' => $label, 'text' => $text, ); } if ( count( $steps ) < self::MIN_HOWTO_STEPS ) { return null; } return array( 'name' => $name, 'steps' => $steps, ); } }