PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 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 All 508 releases
← All changes | jetpack_vendor/automattic/jetpack-seo/src/class-post-schema-node.php +163 -0 16.2-beta → 16.3 View file →
@@ -1,0 +1,163 @@
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 +}