`summary` pair. * * @since 1.5.0 * @var string */ public const SOURCE_DETAILS = 'details'; /** * Answer classification: the first eligible content answers directly. * * @since 1.5.0 * @var string */ public const DIRECT = 'direct'; /** * Answer classification: content exists but does not answer directly. * * @since 1.5.0 * @var string */ public const BURIED = 'buried'; /** * Answer classification: no eligible content precedes the boundary. * * @since 1.5.0 * @var string */ public const UNANSWERED = 'unanswered'; /** * Analyses stored post content and returns its question breakdown. * * @since 1.5.0 * * @param string $raw_content Stored `post_content`. * @return array{state: string, diagnostics: array, content_hash: string, total_headings: int, questions: array} Analysis result. */ public static function analyze( $raw_content ) { $raw_content = is_string( $raw_content ) ? $raw_content : ''; $content_hash = hash( 'sha256', $raw_content ); if ( strlen( $raw_content ) > self::MAX_BYTES ) { return self::error_result( $content_hash, self::DIAGNOSTIC_CONTENT_LIMIT ); } if ( '' === Helpers::normalize_text( wp_strip_all_tags( $raw_content, false ) ) ) { return self::empty_result( $content_hash, self::DIAGNOSTIC_EMPTY_CONTENT ); } $document = Helpers::parse_html_fragment( $raw_content ); if ( null === $document ) { return self::empty_result( $content_hash, self::DIAGNOSTIC_PARSE_FAILED ); } $total_headings = 0; $questions = array(); $inspected = 0; foreach ( $document->getElementsByTagName( '*' ) as $element ) { if ( ! $element instanceof DOMElement ) { continue; } ++$inspected; if ( $inspected > self::MAX_ELEMENTS ) { return self::error_result( $content_hash, self::DIAGNOSTIC_CONTENT_LIMIT ); } $level = Helpers::get_heading_level( $element ); if ( $level >= 2 && $level <= 6 ) { $text = Helpers::normalize_text( $element->textContent ); if ( '' === $text ) { continue; } ++$total_headings; if ( Helpers::is_question_text( $text ) ) { $questions[] = array( 'kind' => self::SOURCE_HEADING, 'level' => $level, 'text' => $text, 'classification' => self::classify_heading_section( $element, $level ), ); } continue; } if ( 'summary' !== strtolower( $element->tagName ) ) { continue; } $details = $element->parentNode; if ( ! $details instanceof DOMElement || 'details' !== strtolower( $details->tagName ) ) { continue; } $text = Helpers::normalize_text( $element->textContent ); if ( '' === $text || ! Helpers::is_question_text( $text ) ) { continue; } $questions[] = array( 'kind' => self::SOURCE_DETAILS, 'level' => null, 'text' => $text, 'classification' => self::classify_details( $details, $element ), ); } return array( 'state' => self::STATE_SCANNED, 'diagnostics' => array(), 'content_hash' => $content_hash, 'total_headings' => $total_headings, 'questions' => $questions, ); } /** * Classifies a heading question's answer. * * Walks the heading's bounded section in document order and stops at the * first non-empty eligible node. A nested heading reached before any such * node means the parent's section borrowed nothing: the subtree belongs to * the nested question, not to this one, so the parent is unanswered. * * @since 1.5.0 * * @param DOMElement $heading Question heading. * @param int $level Heading level, 2-6. * @return string Classification. */ private static function classify_heading_section( DOMElement $heading, $level ) { for ( $node = $heading->nextSibling; null !== $node; $node = $node->nextSibling ) { if ( $node instanceof DOMElement ) { $node_level = Helpers::get_heading_level( $node ); if ( $node_level > 0 && $node_level <= $level ) { // End of this heading's section; nothing eligible preceded it. break; } if ( $node_level >= 2 && $node_level <= 6 ) { // A nested heading precedes any eligible content of its own. return self::UNANSWERED; } $result = self::classify_node( $node ); if ( null !== $result ) { return $result; } // An empty element; keep looking inside the same section. continue; } if ( XML_TEXT_NODE === $node->nodeType ) { $text = Helpers::normalize_text( $node->textContent ); if ( '' !== $text ) { // Bare text, not inside a qualifying wrapper. return self::BURIED; } } // Whitespace-only text, comments and other node types are skipped. } return self::UNANSWERED; } /** * Classifies one candidate "first eligible node", or reports it is empty. * * @since 1.5.0 * * @param DOMElement $node Candidate element. * @return string|null Classification, or null when the element carries no text and the search should continue. */ private static function classify_node( DOMElement $node ) { $tag = strtolower( $node->tagName ); $text = Helpers::normalize_text( $node->textContent ); if ( in_array( $tag, array( 'ul', 'ol', 'table', 'dl' ), true ) ) { return '' === $text ? null : self::DIRECT; } if ( '' === $text ) { return null; } if ( 'p' === $tag ) { if ( mb_strlen( $text, 'UTF-8' ) <= self::MAX_DIRECT_PARAGRAPH_CHARS ) { return self::DIRECT; } if ( self::begins_with_strong( $node ) ) { return self::DIRECT; } } return self::is_definition( $text ) ? self::DIRECT : self::BURIED; } /** * Reports whether a paragraph's first content is a `strong` or `b` element. * * Leading whitespace-only text is skipped; any other text ahead of the * element disqualifies it, because the element no longer "begins" the * paragraph. * * @since 1.5.0 * * @param DOMElement $paragraph Paragraph element. * @return bool True when the paragraph begins with strong or bold text. */ private static function begins_with_strong( DOMElement $paragraph ) { foreach ( $paragraph->childNodes as $child ) { if ( XML_TEXT_NODE === $child->nodeType ) { if ( '' !== trim( (string) $child->textContent ) ) { return false; } continue; } if ( $child instanceof DOMElement ) { return in_array( strtolower( $child->tagName ), array( 'strong', 'b' ), true ); } return false; } return false; } /** * Reports whether normalised text opens with a bounded definition phrase. * * English and German share one pattern: a subject of at most * {@see MAX_DEFINITION_TOKENS} whitespace-delimited tokens followed by "is", * "means" or "ist" on a word boundary. Chinese and Japanese are written * without inter-word spaces, so the token bound is approximated as the same * number of leading characters immediately before the marker - an * engineering judgement call the contract leaves to this implementation, * the same way SchemaSource bounds a marker region's byte length. * * @since 1.5.0 * * @param string $text Normalised candidate text. * @return bool True when the text reads as a definition. */ private static function is_definition( $text ) { $tokens = self::MAX_DEFINITION_TOKENS; if ( 1 === preg_match( '/^(?:\S+\s+){0,' . ( $tokens - 1 ) . '}\S+\s+(?:is|means|ist)\b/iu', $text ) ) { return true; } return 1 === preg_match( '/^.{1,' . $tokens . '}(?:是|とは)/u', $text ); } /** * Classifies a `details` question's answer. * * @since 1.5.0 * * @param DOMElement $details Details element. * @param DOMElement $summary Its summary element. * @return string Classification; never buried, per the contract. */ private static function classify_details( DOMElement $details, DOMElement $summary ) { return '' === self::details_answer_text( $details, $summary ) ? self::UNANSWERED : self::DIRECT; } /** * Returns the normalised text of a `details` element, excluding its * `summary` and any nested `details` subtree. * * Nested `details` are excluded entirely - not merely their own summary - * so that an inner question's answer is not also counted as part of the * outer one. * * @since 1.5.0 * * @param DOMElement $details Details element. * @param DOMElement $summary Its own summary element. * @return string Normalised answer text. */ private static function details_answer_text( DOMElement $details, DOMElement $summary ) { $text = ''; foreach ( $details->childNodes as $child ) { if ( $child === $summary ) { continue; } $text .= ' ' . self::text_excluding_details( $child ); } return Helpers::normalize_text( $text ); } /** * Returns a node's raw text, dropping any `details` subtree it contains. * * @since 1.5.0 * * @param DOMNode $node Node to read. * @return string Raw concatenated text. */ private static function text_excluding_details( DOMNode $node ) { if ( $node instanceof DOMElement && 'details' === strtolower( $node->tagName ) ) { return ''; } if ( ! $node->hasChildNodes() ) { return (string) $node->textContent; } $text = ''; foreach ( $node->childNodes as $child ) { $text .= ' ' . self::text_excluding_details( $child ); } return $text; } /** * Builds an over-budget error result. * * @since 1.5.0 * * @param string $content_hash Hash of the raw input. * @param string $diagnostic Diagnostic code. * @return array{state: string, diagnostics: array, content_hash: string, total_headings: int, questions: array} Error result. */ private static function error_result( $content_hash, $diagnostic ) { return array( 'state' => self::STATE_ERROR, 'diagnostics' => array( $diagnostic ), 'content_hash' => $content_hash, 'total_headings' => 0, 'questions' => array(), ); } /** * Builds a successful zero-question result, for empty or unparsable input. * * @since 1.5.0 * * @param string $content_hash Hash of the raw input. * @param string $diagnostic Diagnostic code. * @return array{state: string, diagnostics: array, content_hash: string, total_headings: int, questions: array} Successful empty result. */ private static function empty_result( $content_hash, $diagnostic ) { return array( 'state' => self::STATE_SCANNED, 'diagnostics' => array( $diagnostic ), 'content_hash' => $content_hash, 'total_headings' => 0, 'questions' => array(), ); } }