Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.83% covered (success)
94.83%
55 / 58
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
Post_Schema_Node
94.83% covered (success)
94.83%
55 / 58
50.00% covered (danger)
50.00%
2 / 4
21.06
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
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
3.00
 build_faq
93.10% covered (success)
93.10%
27 / 29
0.00% covered (danger)
0.00%
0 / 1
9.03
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_HTML_Decoder::decode_text_node( 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        );
92
93        $image = get_the_post_thumbnail_url( $post, 'full' );
94        if ( $image ) {
95            $node['image'] = $image;
96        }
97
98        // @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_SEO_Posts lives in plugins/jetpack; Schema_Builder::emit() guards on class_exists.
99        $description = Jetpack_SEO_Posts::get_post_description( $post );
100        if ( $description ) {
101            // Cap it: get_post_description() falls back to full post_content, which
102            // would otherwise dump the whole body into the markup. Decode last, or
103            // wp_trim_words() would strip decoded text such as "<div>".
104            $node['description'] = \WP_HTML_Decoder::decode_text_node( wp_trim_words( wp_strip_all_tags( $description ), self::DESCRIPTION_MAX_WORDS, '' ) );
105        }
106
107        return $node;
108    }
109
110    /**
111     * FAQPage JSON-LD, parsed from `core/details` blocks (summary = question,
112     * rendered content = answer). Returns null when the post has none, so we
113     * never emit an empty/invalid FAQPage.
114     *
115     * @param WP_Post $post The post.
116     * @return array|null
117     */
118    private static function build_faq( WP_Post $post ) {
119        if ( ! function_exists( 'parse_blocks' ) ) {
120            return null;
121        }
122
123        // render_block() skips the `the_content` chain, so the paywall never sees this text.
124        if ( Content_Gate::is_gated( $post ) ) {
125            return null;
126        }
127
128        $items = array();
129        foreach ( parse_blocks( $post->post_content ) as $block ) {
130            if ( 'core/details' !== ( $block['blockName'] ?? '' ) ) {
131                continue;
132            }
133            $question = trim( \WP_HTML_Decoder::decode_text_node( wp_strip_all_tags( (string) ( $block['innerHTML'] ?? '' ) ) ) );
134
135            // Render only the inner blocks for the answer. Rendering the whole
136            // core/details block would re-include the <summary> (the question).
137            $answer_html = '';
138            foreach ( $block['innerBlocks'] ?? array() as $inner_block ) {
139                $answer_html .= render_block( $inner_block );
140            }
141            $answer = trim( \WP_HTML_Decoder::decode_text_node( wp_strip_all_tags( $answer_html ) ) );
142            if ( '' === $question || '' === $answer ) {
143                continue;
144            }
145            $items[] = array(
146                '@type'          => 'Question',
147                'name'           => $question,
148                'acceptedAnswer' => array(
149                    '@type' => 'Answer',
150                    'text'  => $answer,
151                ),
152            );
153        }
154
155        if ( empty( $items ) ) {
156            return null;
157        }
158
159        return array(
160            '@type'      => 'FAQPage',
161            'mainEntity' => $items,
162        );
163    }
164}