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;
}
}