Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions projects/packages/seo/changelog/fix-seo-faq-schema-summary
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: fixed

FAQ schema: read the question from the saved `<summary>` markup so FAQPage JSON-LD emits from real editor-saved Details blocks.
37 changes: 35 additions & 2 deletions projects/packages/seo/src/class-post-schema-node.php
Original file line number Diff line number Diff line change
Expand Up @@ -128,15 +128,15 @@ private static function build_faq( WP_Post $post ) {
if ( 'core/details' !== ( $block['blockName'] ?? '' ) ) {
continue;
}
$question = trim( (string) ( $block['attrs']['summary'] ?? '' ) );
$question = self::question_from_details_block( $block );

// Render only the inner blocks for the answer. Rendering the whole
// core/details block would re-include the <summary> (the question).
$answer_html = '';
foreach ( $block['innerBlocks'] ?? array() as $inner_block ) {
$answer_html .= render_block( $inner_block );
}
$answer = trim( wp_strip_all_tags( $answer_html ) );
$answer = self::to_plain_text( $answer_html );
if ( '' === $question || '' === $answer ) {
continue;
}
Expand All @@ -159,4 +159,37 @@ private static function build_faq( WP_Post $post ) {
'mainEntity' => $items,
);
}

/**
* Extract the question text from a `core/details` block's `<summary>`.
*
* The Details block declares `summary` as a `source: "rich-text"` attribute,
* so the value is saved in the `<summary>…</summary>` markup, not in the
* `<!-- wp:details … -->` comment. `parse_blocks()` does not resolve
* source-based attributes (it only returns what's written into the comment),
* so `$block['attrs']['summary']` is always empty for real, editor-saved
* blocks. The summary text does survive in the block's inner HTML, so we read
* it from there instead.
*
* @param array $block A parsed `core/details` block.
* @return string The plain-text question, or '' when the block has no summary.
*/
private static function question_from_details_block( array $block ) {
$inner_html = (string) ( $block['innerHTML'] ?? '' );
if ( ! preg_match( '#<summary\b[^>]*>(.*?)</summary>#is', $inner_html, $matches ) ) {
return '';
}
return self::to_plain_text( $matches[1] );
}

/**
* Reduce a fragment of post HTML to the plain text used for a schema value:
* tags stripped, entities decoded, surrounding whitespace trimmed.
*
* @param string $html HTML fragment.
* @return string
*/
private static function to_plain_text( $html ) {
return trim( html_entity_decode( wp_strip_all_tags( (string) $html ), ENT_QUOTES, 'UTF-8' ) );
}
}
53 changes: 49 additions & 4 deletions projects/packages/seo/tests/php/PostSchemaNodeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -131,13 +131,18 @@ public function test_published_page_has_no_default_schema() {
}

/**
* FAQPage answers are built from a `core/details` block's inner blocks only,
* so the question (the `<summary>`) is not duplicated into the answer text.
* The FAQ question is read from the saved `<summary>` markup, and the answer
* from the inner blocks only — so the question is not duplicated into the
* answer text. The fixture uses realistic, editor-saved markup: the summary
* lives only in `<summary>` (a `source: "rich-text"` attribute), never in the
* `<!-- wp:details -->` comment, which is what `parse_blocks()` actually
* returns. Baking the summary into the comment instead would mask the bug
* this builder has to handle (see JETPACK-1793).
*/
public function test_faq_answer_excludes_the_question() {
public function test_faq_question_from_summary_and_answer_excludes_it() {
\Jetpack_SEO_Posts::$schema_type = 'faq';

$content = '<!-- wp:details {"summary":"What is SEO?"} -->';
$content = '<!-- wp:details -->';
$content .= '<details class="wp-block-details"><summary>What is SEO?</summary>';
$content .= '<!-- wp:paragraph --><p>Search engine optimization.</p><!-- /wp:paragraph -->';
$content .= '</details><!-- /wp:details -->';
Expand All @@ -155,6 +160,46 @@ public function test_faq_answer_excludes_the_question() {
$this->assertStringNotContainsString( 'What is SEO?', $item['acceptedAnswer']['text'] );
}

/**
* The summary is rich text, so it may carry inline formatting and HTML
* entities. The question must be reduced to decoded plain text, and multiple
* Details blocks each become their own Question entity in document order.
*/
public function test_faq_summary_is_decoded_plain_text_across_multiple_blocks() {
\Jetpack_SEO_Posts::$schema_type = 'faq';

$content = '<!-- wp:details -->';
$content .= '<details class="wp-block-details"><summary>What is <strong>SEO</strong> &amp; AEO?</summary>';
$content .= '<!-- wp:paragraph --><p>Optimization for search and answer engines.</p><!-- /wp:paragraph -->';
$content .= '</details><!-- /wp:details -->';
$content .= '<!-- wp:details -->';
$content .= '<details class="wp-block-details"><summary>Is it free?</summary>';
$content .= '<!-- wp:paragraph --><p>Yes.</p><!-- /wp:paragraph -->';
$content .= '</details><!-- /wp:details -->';

$node = Post_Schema_Node::build( $this->make_post( array( 'post_content' => $content ) ) );

$this->assertIsArray( $node );
$this->assertCount( 2, $node['mainEntity'] );
$this->assertSame( 'What is SEO & AEO?', $node['mainEntity'][0]['name'] );
$this->assertSame( 'Is it free?', $node['mainEntity'][1]['name'] );
}

/**
* A Details block whose `<summary>` is empty produces no question, so it is
* skipped rather than emitting a Question with a blank name.
*/
public function test_faq_skips_details_block_with_empty_summary() {
\Jetpack_SEO_Posts::$schema_type = 'faq';

$content = '<!-- wp:details -->';
$content .= '<details class="wp-block-details"><summary></summary>';
$content .= '<!-- wp:paragraph --><p>An answer with no question.</p><!-- /wp:paragraph -->';
$content .= '</details><!-- /wp:details -->';

$this->assertNull( Post_Schema_Node::build( $this->make_post( array( 'post_content' => $content ) ) ) );
}

/**
* A "faq" override with no `core/details` blocks yields no node, rather than
* an empty/invalid FAQPage.
Expand Down
4 changes: 2 additions & 2 deletions projects/packages/seo/tests/php/SchemaBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ public function test_emits_graph_with_article_for_published_post() {
public function test_emits_graph_with_faqpage_for_faq_override() {
\Jetpack_SEO_Posts::$schema_type = 'faq';

$content = '<!-- wp:details {"summary":"What is SEO?"} -->';
$content = '<!-- wp:details -->';
$content .= '<details class="wp-block-details"><summary>What is SEO?</summary>';
$content .= '<!-- wp:paragraph --><p>Search engine optimization.</p><!-- /wp:paragraph -->';
$content .= '</details><!-- /wp:details -->';
Expand Down Expand Up @@ -235,7 +235,7 @@ public function test_faqpage_has_no_publisher_but_graph_has_organization() {
$this->set_site_name( 'Acme Co' );
\Jetpack_SEO_Posts::$schema_type = 'faq';

$content = '<!-- wp:details {"summary":"What is SEO?"} -->';
$content = '<!-- wp:details -->';
$content .= '<details class="wp-block-details"><summary>What is SEO?</summary>';
$content .= '<!-- wp:paragraph --><p>Search engine optimization.</p><!-- /wp:paragraph -->';
$content .= '</details><!-- /wp:details -->';
Expand Down
Loading