PluginProbe
WpStream – Live Streaming, Video on Demand, Pay Per View / 4.14.1
WpStream – Live Streaming, Video on Demand, Pay Per View v4.14.1
4.14.1 4.14.0 4.13.2 4.13.1 4.13 4.12.5 4.12.4 4.12.3 4.12.2 4.12.1 4.12 4.4.4 4.4.5 4.4.6 4.4.7 4.4.8 4.4.9 4.5 4.5.1 4.5.11 4.5.11.1 4.5.11.2 4.5.11.4 4.5.11.5 4.5.11.6 All 181 releases
wpstream / includes / class-wpstream-seo.php

class-wpstream-seo.php in WpStream – Live Streaming, Video on Demand, Pay Per View 4.14.1, at includes/class-wpstream-seo.php

325 lines 10.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Front-end SEO metadata for WpStream content.
4 *
5 * Emits the share and discovery markup for the three WpStream singular views —
6 * WooCommerce products, free live channels and free VOD:
7 *
8 * - Open Graph + Twitter card tags, so shared links render a rich preview.
9 * - JSON-LD structured data, so search engines understand the page as video
10 * rather than as a generic article.
11 *
12 * The guiding rule is to enrich, never duplicate. Whatever another component
13 * already states correctly is left alone:
14 *
15 * - rel=canonical belongs to WordPress core, which emits one on every
16 * singular view; a second would be an SEO defect.
17 * - Open Graph and Twitter tags belong to a dedicated SEO plugin (Yoast,
18 * Rank Math) whenever one is active.
19 * - Product and Offer structured data belongs to WooCommerce, which already
20 * describes every product's price and availability.
21 *
22 * What no other component can state is that a WpStream page is *video* — a
23 * live broadcast or a recording — so that is this class's contribution, for
24 * free channels and paid stream products alike.
25 *
26 * @package Wpstream
27 */
28
29 // Block direct file access.
30 if ( ! defined( 'ABSPATH' ) ) {
31 exit;
32 }
33
34 /**
35 * Builds and prints WpStream's front-end SEO metadata.
36 */
37 class Wpstream_Seo {
38
39 /**
40 * Number of words kept in the generated share description.
41 *
42 * Open Graph consumers truncate long descriptions anyway; trimming here
43 * keeps the head small and avoids dumping an entire post body into a tag.
44 */
45 const DESCRIPTION_WORDS = 30;
46
47 /**
48 * Register the front-end hooks.
49 *
50 * @return void
51 */
52 public static function init() {
53 // Build the service and print its markup inside the document head.
54 $seo = new self();
55 add_action( 'wp_head', array( $seo, 'render' ) );
56 }
57
58 /**
59 * Print all SEO metadata for the current request.
60 *
61 * Does nothing unless the request is one of the WpStream singular views.
62 *
63 * @return void
64 */
65 public function render() {
66 // Resolve the post being viewed, or bail on any other kind of request.
67 $post = $this->current_post();
68 if ( null === $post ) {
69 return;
70 }
71
72 // Only claim the social surface when no SEO plugin already owns it.
73 if ( ! $this->seo_plugin_active() ) {
74 $this->render_open_graph( $post );
75 }
76
77 // Structured data is always ours: no general-purpose SEO plugin knows
78 // how to describe a WpStream channel or VOD as a video object.
79 $this->render_schema( $post );
80 }
81
82 /**
83 * Print the JSON-LD structured data block for a post.
84 *
85 * @param WP_Post $post The post being viewed.
86 * @return void
87 */
88 private function render_schema( $post ) {
89 /**
90 * Filter the structured data emitted for a WpStream singular view.
91 *
92 * Returning an empty array suppresses the block entirely.
93 *
94 * @param array $schema The JSON-LD structure about to be printed.
95 * @param WP_Post $post The post being described.
96 */
97 $schema = apply_filters( 'wpstream_seo_schema', $this->build_schema( $post ), $post );
98
99 // Nothing to say about this post.
100 if ( empty( $schema ) ) {
101 return;
102 }
103
104 // JSON_HEX_TAG is what makes this block safe to inline: it encodes the
105 // angle brackets as unicode escapes, so post content carrying a closing
106 // script tag cannot terminate the element. JSON_HEX_AMP does the same
107 // for ampersands; JSON_UNESCAPED_SLASHES just keeps the URLs readable.
108 printf(
109 '<script type="application/ld+json">%s</script>' . "\n",
110 wp_json_encode( $schema, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP )
111 );
112 }
113
114 /**
115 * Build the schema.org structure describing a post.
116 *
117 * Live channels and VOD are both VideoObjects; a live channel is set apart
118 * by a BroadcastEvent publication, which is the shape search engines look
119 * for when surfacing a livestream.
120 *
121 * @param WP_Post $post The post being described.
122 * @return array The JSON-LD structure, or an empty array when not applicable.
123 */
124 private function build_schema( $post ) {
125 // Live, recorded, or not WpStream media at all.
126 $kind = $this->media_kind( $post );
127 if ( '' === $kind ) {
128 return array();
129 }
130
131 // Base video description shared by live channels and recordings.
132 $schema = array(
133 '@context' => 'https://schema.org',
134 '@type' => 'VideoObject',
135 'name' => get_the_title( $post ),
136 'description' => $this->description( $post ),
137 'url' => get_permalink( $post ),
138 // Search engines expect an ISO-8601 date here.
139 'uploadDate' => get_post_time( 'c', true, $post ),
140 );
141
142 // Only advertise a thumbnail when the post actually has one.
143 $image = $this->image_url( $post );
144 if ( '' !== $image ) {
145 $schema['thumbnailUrl'] = $image;
146 }
147
148 // A live channel is a broadcast rather than a recording, which is what
149 // search engines look for when surfacing a livestream.
150 if ( 'live' === $kind ) {
151 $schema['publication'] = array(
152 '@type' => 'BroadcastEvent',
153 'isLiveBroadcast' => true,
154 'startDate' => get_post_time( 'c', true, $post ),
155 );
156 }
157
158 return $schema;
159 }
160
161 /**
162 * Classify a post as live streaming, recorded video, or neither.
163 *
164 * Free content is identified by its post type; paid content is an ordinary
165 * WooCommerce product whose product type carries the distinction.
166 *
167 * @param WP_Post $post The post being described.
168 * @return string 'live', 'vod', or an empty string when the post is not
169 * WpStream media.
170 */
171 private function media_kind( $post ) {
172 // Free live channels and free recordings each have their own post type.
173 if ( 'wpstream_product' === $post->post_type ) {
174 return 'live';
175 }
176 if ( 'wpstream_product_vod' === $post->post_type ) {
177 return 'vod';
178 }
179
180 // Anything else can only qualify as a paid WooCommerce stream.
181 if ( 'product' !== $post->post_type || ! function_exists( 'wc_get_product' ) ) {
182 return '';
183 }
184
185 $product = wc_get_product( $post->ID );
186 if ( ! $product ) {
187 return '';
188 }
189
190 // The plugin's two custom product types mirror the free post types.
191 if ( 'live_stream' === $product->get_type() ) {
192 return 'live';
193 }
194 if ( 'video_on_demand' === $product->get_type() ) {
195 return 'vod';
196 }
197
198 // An ordinary shop product is not video and gets nothing from us.
199 return '';
200 }
201
202 /**
203 * Resolve the post for a WpStream singular view.
204 *
205 * @return WP_Post|null The current post, or null when this is not a
206 * WpStream singular view.
207 */
208 private function current_post() {
209 // The share markup applies to paid products and to free live/VOD posts.
210 if ( ! is_singular( array( 'product', 'wpstream_product', 'wpstream_product_vod' ) ) ) {
211 return null;
212 }
213
214 // Read the post from the main query rather than the $post global, so a
215 // stray setup_postdata() elsewhere cannot redirect the tags.
216 $post = get_queried_object();
217
218 // Guard against the queried object being a term or user.
219 return ( $post instanceof WP_Post ) ? $post : null;
220 }
221
222 /**
223 * Detect an active SEO plugin that already emits social metadata.
224 *
225 * @return bool True when Yoast SEO or Rank Math is active.
226 */
227 private function seo_plugin_active() {
228 // Yoast defines its version constant once loaded; Rank Math its class.
229 return defined( 'WPSEO_VERSION' ) || class_exists( 'RankMath' );
230 }
231
232 /**
233 * Print the Open Graph and Twitter card tags for a post.
234 *
235 * @param WP_Post $post The post being viewed.
236 * @return void
237 */
238 private function render_open_graph( $post ) {
239 // Full-size featured image URL, or an empty string when none is set.
240 $image = $this->image_url( $post );
241
242 // A Woo product is a purchasable product; live channels and VOD are video.
243 $type = ( 'product' === $post->post_type ) ? 'product' : 'video.other';
244
245 // Without a picture there is nothing for the large card layout to show.
246 $card = ( '' !== $image ) ? 'summary_large_image' : 'summary';
247
248 // Every value is attribute-escaped: stripping tags from post content
249 // leaves quotes intact, which would otherwise close the content
250 // attribute and let the body inject its own markup.
251 printf(
252 '<meta property="og:title" content="%s" />' . "\n",
253 esc_attr( get_the_title( $post ) )
254 );
255 printf(
256 '<meta property="og:url" content="%s" />' . "\n",
257 esc_url( get_permalink( $post ) )
258 );
259 printf(
260 '<meta property="og:type" content="%s" />' . "\n",
261 esc_attr( $type )
262 );
263 printf(
264 '<meta property="og:site_name" content="%s" />' . "\n",
265 esc_attr( get_bloginfo( 'name' ) )
266 );
267 printf(
268 '<meta property="og:description" content="%s" />' . "\n",
269 esc_attr( $this->description( $post ) )
270 );
271
272 // Both image tags are only meaningful when a featured image exists.
273 if ( '' !== $image ) {
274 printf(
275 '<meta property="og:image" content="%s" />' . "\n",
276 esc_url( $image )
277 );
278 printf(
279 '<meta property="og:image:secure_url" content="%s" />' . "\n",
280 esc_url( $image )
281 );
282 }
283
284 // Twitter reads title, description and picture from the og:* tags above;
285 // the card type is the only value with no Open Graph equivalent.
286 printf(
287 '<meta name="twitter:card" content="%s" />' . "\n",
288 esc_attr( $card )
289 );
290 }
291
292 /**
293 * Build the plain-text share description for a post.
294 *
295 * @param WP_Post $post The post being viewed.
296 * @return string Tag-free, trimmed description text.
297 */
298 private function description( $post ) {
299 // Strip markup first, then shorten to a share-sized summary.
300 return wp_trim_words( wp_strip_all_tags( $post->post_content ), self::DESCRIPTION_WORDS );
301 }
302
303 /**
304 * Resolve the full-size featured image URL for a post.
305 *
306 * @param WP_Post $post The post being viewed.
307 * @return string Image URL, or an empty string when no featured image is set.
308 */
309 private function image_url( $post ) {
310 // Featured image attachment id, 0 when the post has none.
311 $image_id = get_post_thumbnail_id( $post );
312 if ( ! $image_id ) {
313 return '';
314 }
315
316 // wp_get_attachment_image_src() returns [url, width, height, is_resized].
317 $src = wp_get_attachment_image_src( $image_id, 'full' );
318
319 return ( is_array( $src ) && ! empty( $src[0] ) ) ? $src[0] : '';
320 }
321 }
322
323 // Wire the front-end hooks as soon as this file is loaded.
324 Wpstream_Seo::init();
325