Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.86% covered (success)
92.86%
52 / 56
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
Post_Schema_Node
92.86% covered (success)
92.86%
52 / 56
50.00% covered (danger)
50.00%
2 / 4
20.15
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
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
3.01
 build_faq
92.59% covered (success)
92.59%
25 / 27
0.00% covered (danger)
0.00%
0 / 1
8.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_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.
103            $node['description'] = wp_trim_words( wp_strip_all_tags( $description ), self::DESCRIPTION_MAX_WORDS, '' );
104        }
105
106        return $node;
107    }
108
109    /**
110     * FAQPage JSON-LD, parsed from `core/details` blocks (summary = question,
111     * rendered content = answer). Returns null when the post has none, so we
112     * never emit an empty/invalid FAQPage.
113     *
114     * @param WP_Post $post The post.
115     * @return array|null
116     */
117    private static function build_faq( WP_Post $post ) {
118        if ( ! function_exists( 'parse_blocks' ) ) {
119            return null;
120        }
121
122        $items = array();
123        foreach ( parse_blocks( $post->post_content ) as $block ) {
124            if ( 'core/details' !== ( $block['blockName'] ?? '' ) ) {
125                continue;
126            }
127            $question = trim( html_entity_decode( wp_strip_all_tags( (string) ( $block['innerHTML'] ?? '' ) ), ENT_QUOTES, 'UTF-8' ) );
128
129            // Render only the inner blocks for the answer. Rendering the whole
130            // core/details block would re-include the <summary> (the question).
131            $answer_html = '';
132            foreach ( $block['innerBlocks'] ?? array() as $inner_block ) {
133                $answer_html .= render_block( $inner_block );
134            }
135            $answer = trim( html_entity_decode( wp_strip_all_tags( $answer_html ), ENT_QUOTES, 'UTF-8' ) );
136            if ( '' === $question || '' === $answer ) {
137                continue;
138            }
139            $items[] = array(
140                '@type'          => 'Question',
141                'name'           => $question,
142                'acceptedAnswer' => array(
143                    '@type' => 'Answer',
144                    'text'  => $answer,
145                ),
146            );
147        }
148
149        if ( empty( $items ) ) {
150            return null;
151        }
152
153        return array(
154            '@type'      => 'FAQPage',
155            'mainEntity' => $items,
156        );
157    }
158}