Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.85% covered (success)
93.85%
61 / 65
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Post_Schema_Node
93.85% covered (success)
93.85%
61 / 65
50.00% covered (danger)
50.00%
3 / 6
23.12
0.00% covered (danger)
0.00%
0 / 1
 build
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
7
 default_schema_for_post
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 build_article
90.48% covered (success)
90.48%
19 / 21
0.00% covered (danger)
0.00%
0 / 1
3.01
 build_faq
96.30% covered (success)
96.30%
26 / 27
0.00% covered (danger)
0.00%
0 / 1
8
 question_from_details_block
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 to_plain_text
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Per-post Schema.org node builder.
4 *
5 * Builds the page-level JSON-LD node for the queried singular post: Article (the
6 * default for standard posts) and FAQPage (when the post uses `core/details`
7 * blocks). The type follows the per-post `jetpack_seo_schema_type` override when
8 * set, otherwise a sensible default by post type. Returns an array node or null
9 * when the post should not emit structured data, so the graph can skip it.
10 *
11 * @package automattic/jetpack-seo-package
12 */
13
14namespace Automattic\Jetpack\SEO;
15
16use Jetpack_SEO_Posts;
17use WP_Post;
18
19/**
20 * Builds the Article / FAQPage node for a single post.
21 */
22class Post_Schema_Node {
23
24    /**
25     * Max words kept for a schema `description`, so a long post body doesn't
26     * dump its full content into the markup.
27     */
28    const DESCRIPTION_MAX_WORDS = 55;
29
30    /**
31     * Build the JSON-LD node for the queried post, or null when none applies.
32     *
33     * @param WP_Post|null $post The queried post.
34     * @return array|null
35     */
36    public static function build( $post ) {
37        if ( ! ( $post instanceof WP_Post ) ) {
38            return null;
39        }
40
41        // Only emit structured data for published content. Previews, drafts, and
42        // private posts are viewable by logged-in users (and may be edge-cached),
43        // so we must not output JSON-LD for anything that isn't publicly published.
44        if ( 'publish' !== $post->post_status ) {
45            return null;
46        }
47
48        // @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_SEO_Posts lives in plugins/jetpack; Schema_Builder::emit() guards on class_exists.
49        $override = Jetpack_SEO_Posts::get_post_schema_type( $post );
50        $type     = '' !== $override ? $override : self::default_schema_for_post( $post );
51
52        switch ( $type ) {
53            case 'faq':
54                return self::build_faq( $post );
55            case 'article':
56                return self::build_article( $post );
57            default:
58                return null;
59        }
60    }
61
62    /**
63     * Default Schema type for a post when the user has not set an override:
64     * Article for standard posts, none for pages, attachments, or custom types.
65     *
66     * @param WP_Post $post The post.
67     * @return string
68     */
69    private static function default_schema_for_post( WP_Post $post ) {
70        // Only standard posts get Article schema by default; everything else
71        // (pages, attachments, custom post types) requires an explicit override.
72        return 'post' === $post->post_type ? 'article' : '';
73    }
74
75    /**
76     * Article JSON-LD.
77     *
78     * @param WP_Post $post The post.
79     * @return array
80     */
81    private static function build_article( WP_Post $post ) {
82        $node = array(
83            '@type'            => 'Article',
84            'headline'         => wp_strip_all_tags( get_the_title( $post ) ),
85            'datePublished'    => get_post_time( 'c', true, $post ),
86            'dateModified'     => get_post_modified_time( 'c', true, $post ),
87            'mainEntityOfPage' => array(
88                '@type' => 'WebPage',
89                '@id'   => get_permalink( $post ),
90            ),
91            'author'           => array(
92                '@type' => 'Person',
93                'name'  => get_the_author_meta( 'display_name', (int) $post->post_author ),
94            ),
95        );
96
97        $image = get_the_post_thumbnail_url( $post, 'full' );
98        if ( $image ) {
99            $node['image'] = $image;
100        }
101
102        // @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_SEO_Posts lives in plugins/jetpack; Schema_Builder::emit() guards on class_exists.
103        $description = Jetpack_SEO_Posts::get_post_description( $post );
104        if ( $description ) {
105            // Cap it: get_post_description() falls back to full post_content, which
106            // would otherwise dump the whole body into the markup.
107            $node['description'] = wp_trim_words( wp_strip_all_tags( $description ), self::DESCRIPTION_MAX_WORDS, '' );
108        }
109
110        return $node;
111    }
112
113    /**
114     * FAQPage JSON-LD, parsed from `core/details` blocks (summary = question,
115     * rendered content = answer). Returns null when the post has none, so we
116     * never emit an empty/invalid FAQPage.
117     *
118     * @param WP_Post $post The post.
119     * @return array|null
120     */
121    private static function build_faq( WP_Post $post ) {
122        if ( ! function_exists( 'parse_blocks' ) ) {
123            return null;
124        }
125
126        $items = array();
127        foreach ( parse_blocks( $post->post_content ) as $block ) {
128            if ( 'core/details' !== ( $block['blockName'] ?? '' ) ) {
129                continue;
130            }
131            $question = self::question_from_details_block( $block );
132
133            // Render only the inner blocks for the answer. Rendering the whole
134            // core/details block would re-include the <summary> (the question).
135            $answer_html = '';
136            foreach ( $block['innerBlocks'] ?? array() as $inner_block ) {
137                $answer_html .= render_block( $inner_block );
138            }
139            $answer = self::to_plain_text( $answer_html );
140            if ( '' === $question || '' === $answer ) {
141                continue;
142            }
143            $items[] = array(
144                '@type'          => 'Question',
145                'name'           => $question,
146                'acceptedAnswer' => array(
147                    '@type' => 'Answer',
148                    'text'  => $answer,
149                ),
150            );
151        }
152
153        if ( empty( $items ) ) {
154            return null;
155        }
156
157        return array(
158            '@type'      => 'FAQPage',
159            'mainEntity' => $items,
160        );
161    }
162
163    /**
164     * Extract the question text from a `core/details` block's `<summary>`.
165     *
166     * The Details block declares `summary` as a `source: "rich-text"` attribute,
167     * so the value is saved in the `<summary>…</summary>` markup, not in the
168     * `<!-- wp:details … -->` comment. `parse_blocks()` does not resolve
169     * source-based attributes (it only returns what's written into the comment),
170     * so `$block['attrs']['summary']` is always empty for real, editor-saved
171     * blocks. The summary text does survive in the block's inner HTML, so we read
172     * it from there instead.
173     *
174     * @param array $block A parsed `core/details` block.
175     * @return string The plain-text question, or '' when the block has no summary.
176     */
177    private static function question_from_details_block( array $block ) {
178        $inner_html = (string) ( $block['innerHTML'] ?? '' );
179        if ( ! preg_match( '#<summary\b[^>]*>(.*?)</summary>#is', $inner_html, $matches ) ) {
180            return '';
181        }
182        return self::to_plain_text( $matches[1] );
183    }
184
185    /**
186     * Reduce a fragment of post HTML to the plain text used for a schema value:
187     * tags stripped, entities decoded, surrounding whitespace trimmed.
188     *
189     * @param string $html HTML fragment.
190     * @return string
191     */
192    private static function to_plain_text( $html ) {
193        return trim( html_entity_decode( wp_strip_all_tags( (string) $html ), ENT_QUOTES, 'UTF-8' ) );
194    }
195}