ID, '_geo_exclude', true ) ) { return; } $faqs = $this->extract_faqs( $post->post_content ); if ( empty( $faqs ) ) { return; } $schema = $this->build_schema( $faqs ); // JSON_HEX_TAG escapes <, >, & to prevent breaking out of the \n"; } /** * Extracts FAQs from the content. * * @since 1.0.0 * @param string $content Post content. * @return array Array of FAQs. */ private function extract_faqs( $content ) { $faqs = array(); // Process Gutenberg blocks. $content = do_blocks( $content ); // Pattern to find H2 and H3 that are questions. $pattern = '/]*>(.*?)<\/h[23]>/is'; preg_match_all( $pattern, $content, $headings, PREG_OFFSET_CAPTURE ); if ( empty( $headings[0] ) ) { return $faqs; } foreach ( $headings[0] as $index => $heading_match ) { $heading_html = $heading_match[0]; $heading_position = $heading_match[1]; $heading_text = wp_strip_all_tags( $headings[1][ $index ][0] ); // Check whether it looks like a question. if ( ! $this->is_question( $heading_text ) ) { continue; } // Find the content between this heading and the next one. $next_position = isset( $headings[0][ $index + 1 ] ) ? $headings[0][ $index + 1 ][1] : strlen( $content ); $answer_html = substr( $content, $heading_position + strlen( $heading_html ), $next_position - $heading_position - strlen( $heading_html ) ); $answer_text = $this->clean_answer( $answer_html ); // Only add when the answer has meaningful content. if ( ! empty( $answer_text ) && strlen( $answer_text ) > 20 ) { $faqs[] = array( 'question' => $this->clean_question( $heading_text ), 'answer' => $answer_text, ); } } return $faqs; } /** * Checks whether the text is a question. * * @since 1.0.0 * @param string $text Text to check. * @return bool True when it is a question. */ private function is_question( $text ) { $text = trim( $text ); // Contains a question mark. if ( strpos( $text, '?' ) !== false ) { return true; } // Starts with Italian question words. $question_words_it = array( 'come', 'cosa', 'cos\'è', 'quando', 'dove', 'perché', 'perche', 'chi', 'quale', 'quali', 'quanto', 'quanti', 'quante', ); // Starts with English question words. $question_words_en = array( 'what', 'why', 'how', 'when', 'where', 'who', 'which', 'is', 'are', 'can', 'do', 'does', 'should', 'will', ); $question_words = array_merge( $question_words_it, $question_words_en ); $first_word = strtolower( explode( ' ', $text )[0] ); return in_array( $first_word, $question_words, true ); } /** * Cleans up the question. * * @since 1.0.0 * @param string $text Question text. * @return string Cleaned question. */ private function clean_question( $text ) { $text = wp_strip_all_tags( $text ); $text = trim( $text ); // Add ? when missing and the text looks like a question. if ( substr( $text, -1 ) !== '?' ) { $text .= '?'; } return $text; } /** * Cleans up the answer. * * @since 1.0.0 * @param string $html Answer HTML. * @return string Cleaned answer. */ private function clean_answer( $html ) { // Remove shortcodes. $text = strip_shortcodes( $html ); // Strip HTML tags. $text = wp_strip_all_tags( $text ); // Clean whitespace. $text = preg_replace( '/\s+/', ' ', $text ); $text = trim( $text ); // Limit length to avoid overly long schemas. if ( strlen( $text ) > 500 ) { $text = wp_trim_words( $text, 80, '...' ); } return $text; } /** * Builds the FAQ schema. * * @since 1.0.0 * @param array $faqs FAQ array. * @return array FAQ schema. */ private function build_schema( $faqs ) { $schema = array( '@context' => 'https://schema.org', '@type' => 'FAQPage', 'mainEntity' => array(), ); foreach ( $faqs as $faq ) { $schema['mainEntity'][] = array( '@type' => 'Question', 'name' => $faq['question'], 'acceptedAnswer' => array( '@type' => 'Answer', 'text' => $faq['answer'], ), ); } return $schema; } }