PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.7
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.7
16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 All 506 releases
jetpack / jetpack_vendor / automattic / jetpack-seo / src / class-post-schema-node.php

class-post-schema-node.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.7, at jetpack_vendor/automattic/jetpack-seo/src/class-post-schema-node.php

164 lines 5.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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
14 namespace Automattic\Jetpack\SEO;
15
16 use Jetpack_SEO_Posts;
17 use WP_Post;
18
19 /**
20 * Builds the Article / FAQPage node for a single post.
21 */
22 class 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 // render_block() skips the `the_content` chain, so the paywall never sees this text.
123 if ( Content_Gate::is_gated( $post ) ) {
124 return null;
125 }
126
127 $items = array();
128 foreach ( parse_blocks( $post->post_content ) as $block ) {
129 if ( 'core/details' !== ( $block['blockName'] ?? '' ) ) {
130 continue;
131 }
132 $question = trim( html_entity_decode( wp_strip_all_tags( (string) ( $block['innerHTML'] ?? '' ) ), ENT_QUOTES, 'UTF-8' ) );
133
134 // Render only the inner blocks for the answer. Rendering the whole
135 // core/details block would re-include the <summary> (the question).
136 $answer_html = '';
137 foreach ( $block['innerBlocks'] ?? array() as $inner_block ) {
138 $answer_html .= render_block( $inner_block );
139 }
140 $answer = trim( html_entity_decode( wp_strip_all_tags( $answer_html ), ENT_QUOTES, 'UTF-8' ) );
141 if ( '' === $question || '' === $answer ) {
142 continue;
143 }
144 $items[] = array(
145 '@type' => 'Question',
146 'name' => $question,
147 'acceptedAnswer' => array(
148 '@type' => 'Answer',
149 'text' => $answer,
150 ),
151 );
152 }
153
154 if ( empty( $items ) ) {
155 return null;
156 }
157
158 return array(
159 '@type' => 'FAQPage',
160 'mainEntity' => $items,
161 );
162 }
163 }
164