Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.19% covered (warning)
77.19%
362 / 469
20.00% covered (danger)
20.00%
2 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Podcast_Episode_Block
77.19% covered (warning)
77.19%
362 / 469
20.00% covered (danger)
20.00%
2 / 10
468.81
0.00% covered (danger)
0.00%
0 / 1
 register_hooks
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 register_block
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
12
 flag_plan_gated
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 enqueue_view_script
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 load_editor_scripts
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 filter_editor_script_src
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 format_seconds_label
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 resolve_cover_art_url
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
7.90
 render_block
85.52% covered (warning)
85.52%
248 / 290
0.00% covered (danger)
0.00%
0 / 1
151.79
 render_email
82.24% covered (warning)
82.24%
88 / 107
0.00% covered (danger)
0.00%
0 / 1
32.39
1<?php
2/**
3 * Podcast Episode block.
4 *
5 * @package automattic/jetpack-podcast
6 */
7
8namespace Automattic\Jetpack\Podcast;
9
10use Automattic\Jetpack\Assets;
11use Automattic\Jetpack\Blocks;
12
13/**
14 * Registers and renders the Podcast Episode block.
15 */
16class Podcast_Episode_Block {
17
18    /**
19     * Editor script handle.
20     */
21    const EDITOR_HANDLE = 'jetpack-podcast-episode-editor';
22
23    /**
24     * Front-end + editor shared style handle. Side-loaded by
25     * `Assets::register_script` from the sibling `style.css` bundle.
26     */
27    const STYLE_HANDLE = 'jetpack-block-podcast-episode';
28
29    /**
30     * Front-end view script handle. Enqueued from the render callback because
31     * block.json `viewScript` can't resolve the package's dist dir.
32     */
33    const VIEW_HANDLE = 'jetpack-podcast-episode-view';
34
35    /**
36     * Wire the block's actions.
37     */
38    public static function register_hooks() {
39        add_action( 'init', array( __CLASS__, 'register_block' ), 9 );
40        add_action( 'enqueue_block_editor_assets', array( __CLASS__, 'load_editor_scripts' ), 9 );
41    }
42
43    /**
44     * Register the block and its front-end style bundle (auto-enqueued via the
45     * `style` arg whenever the block renders).
46     */
47    public static function register_block() {
48        // Side-loads the sibling style.css and registers it under STYLE_HANDLE.
49        Assets::register_script(
50            self::STYLE_HANDLE,
51            '../../../dist/blocks/podcast-episode/style.js',
52            __FILE__,
53            array(
54                'css_path' => '../../../dist/blocks/podcast-episode/style.css',
55            )
56        );
57
58        // Register in every context so the RSS feed carries the player — how the
59        // WPCOM Reader shows it on Atomic/Jetpack sites.
60        Blocks::jetpack_register_block(
61            __DIR__,
62            array(
63                'render_callback'       => array( __CLASS__, 'render_block' ),
64                'style'                 => self::STYLE_HANDLE,
65                'render_email_callback' => array( __CLASS__, 'render_email' ),
66            )
67        );
68
69        // Flag as plan-gated for the editor upgrade prompt. Priority 11 runs after
70        // jetpack_register_block's own availability action (10), so this wins.
71        // Admin-only: only the editor consumes this availability entry (the
72        // front-end render isn't availability-gated), and the self-hosted access
73        // check can hit WPCOM — it must never run during a front-end page render.
74        if ( is_admin() && class_exists( \Jetpack_Gutenberg::class ) ) {
75            add_action( 'jetpack_register_gutenberg_extensions', array( __CLASS__, 'flag_plan_gated' ), 11 );
76        }
77    }
78
79    /**
80     * Gate unpaid sites while keeping existing episodes editable when no upgrade is available.
81     */
82    public static function flag_plan_gated() {
83        if ( Podcast_Gate::has_product_access() ) {
84            return;
85        }
86
87        $required_plan = Podcast_Gate::get_required_plan_slug();
88        \Jetpack_Gutenberg::set_extension_unavailable(
89            'podcast-episode',
90            $required_plan ? 'missing_plan' : 'missing_plan__no_offer',
91            array(
92                'required_feature' => 'podcast-episode',
93                'required_plan'    => $required_plan,
94            )
95        );
96    }
97
98    /**
99     * Register + enqueue the front-end view script (wires soundbite buttons).
100     * Called from render so it only ships on pages with the block; dedups internally.
101     */
102    private static function enqueue_view_script() {
103        Assets::register_script(
104            self::VIEW_HANDLE,
105            '../../../dist/blocks/podcast-episode/view.js',
106            __FILE__,
107            array(
108                'in_footer' => true,
109                'enqueue'   => true,
110            )
111        );
112    }
113
114    /**
115     * Enqueue the bundled editor script + style from the package's dist/.
116     */
117    public static function load_editor_scripts() {
118        Assets::register_script(
119            self::EDITOR_HANDLE,
120            '../../../dist/blocks/podcast-episode/editor.js',
121            __FILE__,
122            array(
123                'in_footer'  => true,
124                'enqueue'    => true,
125                'textdomain' => 'jetpack-podcast',
126            )
127        );
128
129        // Only hook the src rewrite while the editor script is loading.
130        add_filter( 'script_loader_src', array( __CLASS__, 'filter_editor_script_src' ), 10, 2 );
131    }
132
133    /**
134     * Rewrite the editor script src to the admin scheme, avoiding a mixed-content
135     * block on WPCOM custom domains without SSL (where `plugins_url()` returns
136     * http but wp-admin is https).
137     *
138     * @param string $src    Script source URL.
139     * @param string $handle Script handle.
140     * @return string
141     */
142    public static function filter_editor_script_src( $src, $handle ) {
143        if ( self::EDITOR_HANDLE !== $handle ) {
144            return $src;
145        }
146
147        $admin_scheme = wp_parse_url( admin_url(), PHP_URL_SCHEME );
148        if ( ! $admin_scheme ) {
149            return $src;
150        }
151
152        return set_url_scheme( $src, $admin_scheme );
153    }
154
155    /**
156     * Format a seconds value as a MM:SS or HH:MM:SS label. Negative input clamps to 0.
157     *
158     * @param float|int|string $seconds Seconds value (Podcasting 2.0 soundbite startTime/duration are floats).
159     * @return string Formatted label (always non-empty; "0:00" for zero input).
160     */
161    private static function format_seconds_label( $seconds ) {
162        $total   = (int) floor( max( 0, (float) $seconds ) );
163        $hours   = (int) floor( $total / 3600 );
164        $minutes = (int) floor( ( $total % 3600 ) / 60 );
165        $secs    = $total % 60;
166
167        if ( $hours > 0 ) {
168            return sprintf( '%d:%02d:%02d', $hours, $minutes, $secs );
169        }
170        return sprintf( '%d:%02d', $minutes, $secs );
171    }
172
173    /**
174     * Resolve the episode cover art URL: episode override → post featured
175     * image → show-level `podcasting_image` option → empty string.
176     *
177     * @param array    $attributes Block attributes.
178     * @param \WP_Post $post       Episode post.
179     * @param string   $size       Featured-image size to request.
180     * @return string
181     */
182    private static function resolve_cover_art_url( array $attributes, $post, $size ) {
183        if ( isset( $attributes['coverArt'] ) && is_array( $attributes['coverArt'] ) && ! empty( $attributes['coverArt']['url'] ) ) {
184            return esc_url_raw( $attributes['coverArt']['url'] );
185        }
186
187        $featured_id = (int) get_post_thumbnail_id( $post );
188        if ( $featured_id ) {
189            $featured_url = (string) wp_get_attachment_image_url( $featured_id, $size );
190            if ( '' !== $featured_url ) {
191                return $featured_url;
192            }
193        }
194
195        return (string) get_option( 'podcasting_image', '' );
196    }
197
198    /**
199     * Render callback. Full player in every context so the RSS feed carries it
200     * (how the WPCOM Reader shows it on Atomic/Jetpack). Email uses render_email().
201     *
202     * @param array     $attributes Block attributes.
203     * @param string    $content    Saved inner content; unused, the block renders its own markup.
204     * @param \WP_Block $block      Parsed block instance, used for post context.
205     * @return string
206     */
207    public static function render_block( $attributes, $content, $block = null ) {
208        if ( empty( $attributes['mediaUrl'] ) ) {
209            return '';
210        }
211
212        // Resolve the backing post: block context (Query Loop / singular) first,
213        // then the global loop.
214        $post_id = 0;
215        if ( $block && isset( $block->context['postId'] ) ) {
216            $post_id = (int) $block->context['postId'];
217        }
218        if ( ! $post_id ) {
219            $post_id = (int) get_the_ID();
220        }
221        if ( ! $post_id ) {
222            return '';
223        }
224        $post = get_post( $post_id );
225        if ( ! $post ) {
226            return '';
227        }
228
229        $media_url = esc_url_raw( $attributes['mediaUrl'] );
230        if ( ! wp_http_validate_url( $media_url ) ) {
231            return '';
232        }
233
234        $media_type     = isset( $attributes['mediaType'] ) && 'video' === $attributes['mediaType'] ? 'video' : 'audio';
235        $mime_type      = isset( $attributes['mediaMimeType'] ) ? (string) $attributes['mediaMimeType'] : '';
236        $episode_number = isset( $attributes['episodeNumber'] ) ? (int) $attributes['episodeNumber'] : 0;
237        $season_number  = isset( $attributes['seasonNumber'] ) ? (int) $attributes['seasonNumber'] : 0;
238        $episode_type   = isset( $attributes['episodeType'] ) ? (string) $attributes['episodeType'] : 'full';
239        $is_explicit    = ! empty( $attributes['explicit'] );
240        $duration       = isset( $attributes['duration'] ) ? (string) $attributes['duration'] : '';
241        $show_poster    = ! isset( $attributes['showPoster'] ) || ! empty( $attributes['showPoster'] );
242        $show_author    = ! isset( $attributes['showAuthor'] ) || ! empty( $attributes['showAuthor'] );
243        $transcript_url = isset( $attributes['transcriptUrl'] ) ? esc_url_raw( $attributes['transcriptUrl'] ) : '';
244        $location_name  = isset( $attributes['locationName'] ) ? (string) $attributes['locationName'] : '';
245        $license        = isset( $attributes['license'] ) ? (string) $attributes['license'] : '';
246        $license_url    = isset( $attributes['licenseUrl'] ) ? esc_url_raw( $attributes['licenseUrl'] ) : '';
247        $people         = isset( $attributes['people'] ) && is_array( $attributes['people'] ) ? $attributes['people'] : array();
248
249        $soundbites           = isset( $attributes['soundbites'] ) && is_array( $attributes['soundbites'] ) ? $attributes['soundbites'] : array();
250        $alternate_enclosures = isset( $attributes['alternateEnclosures'] ) && is_array( $attributes['alternateEnclosures'] ) ? $attributes['alternateEnclosures'] : array();
251
252        // Drop incomplete rows up front so an all-empty list doesn't enqueue the seek script or emit a bare <ul>.
253        $soundbites           = array_values(
254            array_filter(
255                $soundbites,
256                static function ( $soundbite ) {
257                    return is_array( $soundbite ) && isset( $soundbite['startTime'] );
258                }
259            )
260        );
261        $alternate_enclosures = array_values(
262            array_filter(
263                $alternate_enclosures,
264                static function ( $alt ) {
265                    if ( ! is_array( $alt ) || empty( $alt['url'] ) ) {
266                        return false;
267                    }
268                    if ( ! wp_http_validate_url( esc_url_raw( (string) $alt['url'] ) ) ) {
269                        return false;
270                    }
271                    // `type` is required per spec — mirror the feed, which skips typeless entries.
272                    return '' !== ( isset( $alt['type'] ) ? trim( (string) $alt['type'] ) : '' );
273                }
274            )
275        );
276
277        // Only ship the click-to-seek script when there are soundbites to wire.
278        if ( ! empty( $soundbites ) ) {
279            self::enqueue_view_script();
280        }
281
282        if ( '' !== $transcript_url && ! wp_http_validate_url( $transcript_url ) ) {
283            $transcript_url = '';
284        }
285
286        $author_id        = (int) $post->post_author;
287        $title            = get_the_title( $post );
288        $author_name      = $show_author ? get_the_author_meta( 'display_name', $author_id ) : '';
289        $author_url       = $show_author ? esc_url_raw( (string) get_the_author_meta( 'url', $author_id ) ) : '';
290        $publish_date_iso = get_the_date( 'c', $post );
291        $publish_date     = get_the_date( '', $post );
292        $episode_url      = get_permalink( $post );
293        $transcript_type  = isset( $attributes['transcriptType'] ) ? (string) $attributes['transcriptType'] : '';
294
295        // Show-level data backs the `partOfSeries` schema reference.
296        $show_title     = (string) get_option( 'podcasting_title', '' );
297        $show_image_url = (string) get_option( 'podcasting_image', '' );
298        $show_email     = (string) get_option( 'podcasting_email', '' );
299
300        // Resolve cover art unconditionally so schema always carries the image;
301        // `$show_poster` only gates the visible figure/poster.
302        $image_url = self::resolve_cover_art_url( $attributes, $post, 'full' );
303
304        $media_object_type = 'video' === $media_type ? 'VideoObject' : 'AudioObject';
305
306        $wrapper_attributes = get_block_wrapper_attributes();
307
308        ob_start();
309        ?>
310        <div <?php echo $wrapper_attributes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- get_block_wrapper_attributes() returns pre-escaped attribute output. ?>>
311            <article class="jetpack-podcast-episode" itemscope itemtype="https://schema.org/PodcastEpisode">
312                <?php if ( $episode_url ) : ?>
313                    <link itemprop="url" href="<?php echo esc_url( $episode_url ); ?>" />
314                <?php endif; ?>
315                <?php if ( $image_url && $show_poster ) : ?>
316                    <figure class="jetpack-podcast-episode__poster">
317                        <img
318                            src="<?php echo esc_url( $image_url ); ?>"
319                            alt=""
320                            itemprop="image"
321                            loading="lazy"
322                        />
323                    </figure>
324                <?php elseif ( $image_url ) : ?>
325                    <meta itemprop="image" content="<?php echo esc_url( $image_url ); ?>" />
326                <?php endif; ?>
327
328                <div class="jetpack-podcast-episode__body">
329                    <?php if ( $season_number || $episode_number || 'full' !== $episode_type || $is_explicit ) : ?>
330                        <p class="jetpack-podcast-episode__meta-line">
331                            <?php if ( $season_number ) : ?>
332                                <span class="jetpack-podcast-episode__season" itemprop="partOfSeason" itemscope itemtype="https://schema.org/PodcastSeason">
333                                    <meta itemprop="seasonNumber" content="<?php echo esc_attr( (string) $season_number ); ?>" />
334                                    <?php
335                                    /* translators: %d: season number. */
336                                    echo esc_html( sprintf( __( 'Season %d', 'jetpack-podcast' ), $season_number ) );
337                                    ?>
338                                </span>
339                            <?php endif; ?>
340                            <?php if ( $episode_number ) : ?>
341                                <span class="jetpack-podcast-episode__episode-number">
342                                    <meta itemprop="episodeNumber" content="<?php echo esc_attr( (string) $episode_number ); ?>" />
343                                    <?php
344                                    /* translators: %d: episode number. */
345                                    echo esc_html( sprintf( __( 'Episode %d', 'jetpack-podcast' ), $episode_number ) );
346                                    ?>
347                                </span>
348                            <?php endif; ?>
349                            <?php if ( 'trailer' === $episode_type ) : ?>
350                                <span class="jetpack-podcast-episode__badge jetpack-podcast-episode__badge--trailer"><?php esc_html_e( 'Trailer', 'jetpack-podcast' ); ?></span>
351                            <?php elseif ( 'bonus' === $episode_type ) : ?>
352                                <span class="jetpack-podcast-episode__badge jetpack-podcast-episode__badge--bonus"><?php esc_html_e( 'Bonus', 'jetpack-podcast' ); ?></span>
353                            <?php endif; ?>
354                            <?php if ( $is_explicit ) : ?>
355                                <span class="jetpack-podcast-episode__badge jetpack-podcast-episode__badge--explicit" title="<?php esc_attr_e( 'Explicit content', 'jetpack-podcast' ); ?>"><?php echo esc_html( _x( 'E', 'short label for explicit content', 'jetpack-podcast' ) ); ?></span>
356                            <?php endif; ?>
357                        </p>
358                    <?php endif; ?>
359
360                    <?php if ( $title ) : ?>
361                        <h3 class="jetpack-podcast-episode__title" itemprop="name">
362                            <?php if ( $episode_url ) : ?>
363                                <a href="<?php echo esc_url( $episode_url ); ?>"><?php echo esc_html( $title ); ?></a>
364                            <?php else : ?>
365                                <?php echo esc_html( $title ); ?>
366                            <?php endif; ?>
367                        </h3>
368                    <?php endif; ?>
369
370                    <?php if ( $author_name || $publish_date || $duration ) : ?>
371                        <p class="jetpack-podcast-episode__byline">
372                            <?php if ( $author_name ) : ?>
373                                <span class="jetpack-podcast-episode__author" itemprop="author" itemscope itemtype="https://schema.org/Person">
374                                    <?php if ( $author_url ) : ?>
375                                        <a href="<?php echo esc_url( $author_url ); ?>" itemprop="url">
376                                            <span itemprop="name"><?php echo esc_html( $author_name ); ?></span>
377                                        </a>
378                                    <?php else : ?>
379                                        <span itemprop="name"><?php echo esc_html( $author_name ); ?></span>
380                                    <?php endif; ?>
381                                </span>
382                            <?php endif; ?>
383                            <?php if ( $publish_date ) : ?>
384                                <time
385                                    class="jetpack-podcast-episode__date"
386                                    datetime="<?php echo esc_attr( $publish_date_iso ); ?>"
387                                    itemprop="datePublished"
388                                >
389                                    <?php echo esc_html( $publish_date ); ?>
390                                </time>
391                            <?php endif; ?>
392                            <?php if ( $duration ) : ?>
393                                <span class="jetpack-podcast-episode__duration"><?php echo esc_html( $duration ); ?></span>
394                            <?php endif; ?>
395                        </p>
396                    <?php endif; ?>
397
398                    <div
399                        class="jetpack-podcast-episode__player"
400                        itemprop="<?php echo 'video' === $media_type ? 'video' : 'audio'; ?>"
401                        itemscope
402                        itemtype="https://schema.org/<?php echo esc_attr( $media_object_type ); ?>"
403                    >
404                        <meta itemprop="contentUrl" content="<?php echo esc_url( $media_url ); ?>" />
405                        <?php if ( $mime_type ) : ?>
406                            <meta itemprop="encodingFormat" content="<?php echo esc_attr( $mime_type ); ?>" />
407                        <?php endif; ?>
408                        <?php if ( $duration ) : ?>
409                            <meta itemprop="duration" content="<?php echo esc_attr( $duration ); ?>" />
410                        <?php endif; ?>
411                        <?php if ( 'video' === $media_type ) : ?>
412                            <video
413                                class="jetpack-podcast-episode__video"
414                                controls
415                                preload="none"
416                                src="<?php echo esc_url( $media_url ); ?>"
417                                <?php
418                                if ( $image_url && $show_poster ) :
419                                    ?>
420                                    poster="<?php echo esc_url( $image_url ); ?>"<?php endif; ?>
421                                <?php
422                                if ( $mime_type ) :
423                                    ?>
424                                    data-mime="<?php echo esc_attr( $mime_type ); ?>"<?php endif; ?>
425                            ><a href="<?php echo esc_url( $media_url ); ?>"><?php esc_html_e( 'Watch the episode', 'jetpack-podcast' ); ?></a></video>
426                        <?php else : ?>
427                            <audio
428                                class="jetpack-podcast-episode__audio"
429                                controls
430                                preload="none"
431                                src="<?php echo esc_url( $media_url ); ?>"
432                                <?php
433                                if ( $mime_type ) :
434                                    ?>
435                                    data-mime="<?php echo esc_attr( $mime_type ); ?>"<?php endif; ?>
436                            ><a href="<?php echo esc_url( $media_url ); ?>"><?php esc_html_e( 'Listen to the episode', 'jetpack-podcast' ); ?></a></audio>
437                        <?php endif; ?>
438                    </div>
439
440                    <?php if ( ! empty( $soundbites ) ) : ?>
441                        <ul class="jetpack-podcast-episode__soundbites">
442                            <?php
443                            foreach ( $soundbites as $soundbite ) :
444                                if ( ! is_array( $soundbite ) || ! isset( $soundbite['startTime'] ) ) {
445                                    continue;
446                                }
447                                $start_label     = self::format_seconds_label( $soundbite['startTime'] );
448                                $soundbite_title = isset( $soundbite['title'] ) ? trim( (string) $soundbite['title'] ) : '';
449                                // Keep fractional offsets so the seek button and Clip metadata match the feed.
450                                $start_seconds = max( 0, (float) $soundbite['startTime'] );
451                                $end_seconds   = isset( $soundbite['duration'] )
452                                    ? $start_seconds + max( 0, (float) $soundbite['duration'] )
453                                    : null;
454                                ?>
455                                <li
456                                    class="jetpack-podcast-episode__soundbite"
457                                    itemprop="hasPart"
458                                    itemscope
459                                    itemtype="https://schema.org/Clip"
460                                >
461                                    <meta itemprop="startOffset" content="<?php echo esc_attr( (string) $start_seconds ); ?>" />
462                                    <?php if ( null !== $end_seconds ) : ?>
463                                        <meta itemprop="endOffset" content="<?php echo esc_attr( (string) $end_seconds ); ?>" />
464                                    <?php endif; ?>
465                                    <button
466                                        type="button"
467                                        class="jetpack-podcast-episode__soundbite-button"
468                                        data-start-time="<?php echo esc_attr( (string) $start_seconds ); ?>"
469                                    >
470                                        <time class="jetpack-podcast-episode__soundbite-time"><?php echo esc_html( $start_label ); ?></time>
471                                        <?php if ( '' !== $soundbite_title ) : ?>
472                                            <span class="jetpack-podcast-episode__soundbite-title" itemprop="name"><?php echo esc_html( $soundbite_title ); ?></span>
473                                        <?php endif; ?>
474                                    </button>
475                                </li>
476                            <?php endforeach; ?>
477                        </ul>
478                    <?php endif; ?>
479
480                    <?php if ( ! empty( $alternate_enclosures ) ) : ?>
481                        <ul class="jetpack-podcast-episode__alternates">
482                            <?php
483                            foreach ( $alternate_enclosures as $alt ) :
484                                if ( ! is_array( $alt ) || empty( $alt['url'] ) ) {
485                                    continue;
486                                }
487                                $alt_url = esc_url_raw( (string) $alt['url'] );
488                                if ( ! wp_http_validate_url( $alt_url ) ) {
489                                    continue;
490                                }
491                                $alt_type    = isset( $alt['type'] ) ? (string) $alt['type'] : '';
492                                $alt_title   = isset( $alt['title'] ) ? trim( (string) $alt['title'] ) : '';
493                                $alt_lang    = isset( $alt['lang'] ) ? (string) $alt['lang'] : '';
494                                $alt_bitrate = isset( $alt['bitrate'] ) ? (int) $alt['bitrate'] : 0;
495                                $details     = array();
496                                if ( '' !== $alt_lang ) {
497                                    $details[] = $alt_lang;
498                                }
499                                if ( $alt_bitrate > 0 ) {
500                                    $details[] = sprintf(
501                                        /* translators: %d: bitrate in kilobits per second. */
502                                        __( '%d kbps', 'jetpack-podcast' ),
503                                        (int) round( $alt_bitrate / 1000 )
504                                    );
505                                }
506                                if ( '' !== $alt_type ) {
507                                    $details[] = $alt_type;
508                                }
509                                $details_label = $details ? ' (' . implode( ', ', $details ) . ')' : '';
510                                $display_label = '' !== $alt_title ? $alt_title : __( 'Alternative version', 'jetpack-podcast' );
511                                ?>
512                                <li class="jetpack-podcast-episode__alternate">
513                                    <a href="<?php echo esc_url( $alt_url ); ?>"<?php echo '' !== $alt_lang ? ' hreflang="' . esc_attr( $alt_lang ) . '"' : ''; ?>>
514                                        <?php echo esc_html( $display_label . $details_label ); ?>
515                                    </a>
516                                </li>
517                            <?php endforeach; ?>
518                        </ul>
519                    <?php endif; ?>
520
521                    <?php if ( ! empty( $people ) ) : ?>
522                        <ul class="jetpack-podcast-episode__people">
523                            <?php
524                            foreach ( $people as $person ) :
525                                if ( ! is_array( $person ) || empty( $person['name'] ) ) {
526                                    continue;
527                                }
528                                $person_name = (string) $person['name'];
529                                $person_role = isset( $person['role'] ) ? (string) $person['role'] : '';
530                                $person_href = isset( $person['href'] ) ? esc_url_raw( $person['href'] ) : '';
531                                $person_img  = isset( $person['img'] ) ? esc_url_raw( $person['img'] ) : '';
532                                ?>
533                                <li class="jetpack-podcast-episode__person" itemprop="contributor" itemscope itemtype="https://schema.org/Person">
534                                    <?php if ( $person_img ) : ?>
535                                        <img src="<?php echo esc_url( $person_img ); ?>" alt="" loading="lazy" />
536                                    <?php endif; ?>
537                                    <?php if ( $person_href ) : ?>
538                                        <a href="<?php echo esc_url( $person_href ); ?>" itemprop="url">
539                                            <span itemprop="name"><?php echo esc_html( $person_name ); ?></span>
540                                        </a>
541                                    <?php else : ?>
542                                        <span itemprop="name"><?php echo esc_html( $person_name ); ?></span>
543                                    <?php endif; ?>
544                                    <?php if ( $person_role ) : ?>
545                                        <span class="jetpack-podcast-episode__person-role"><?php echo esc_html( $person_role ); ?></span>
546                                    <?php endif; ?>
547                                </li>
548                            <?php endforeach; ?>
549                        </ul>
550                    <?php endif; ?>
551
552                    <?php if ( '' !== $show_title ) : ?>
553                        <div class="jetpack-podcast-episode__series" itemprop="partOfSeries" itemscope itemtype="https://schema.org/PodcastSeries">
554                            <meta itemprop="name" content="<?php echo esc_attr( $show_title ); ?>" />
555                            <?php if ( $show_image_url ) : ?>
556                                <meta itemprop="image" content="<?php echo esc_url( $show_image_url ); ?>" />
557                            <?php endif; ?>
558                            <?php if ( $show_email ) : ?>
559                                <span itemprop="publisher" itemscope itemtype="https://schema.org/Organization">
560                                    <meta itemprop="email" content="<?php echo esc_attr( $show_email ); ?>" />
561                                </span>
562                            <?php endif; ?>
563                        </div>
564                    <?php endif; ?>
565
566                    <?php if ( $transcript_url || $location_name || $license ) : ?>
567                        <ul class="jetpack-podcast-episode__links">
568                            <?php if ( $transcript_url ) : ?>
569                                <li itemprop="transcript" itemscope itemtype="https://schema.org/MediaObject">
570                                    <meta itemprop="contentUrl" content="<?php echo esc_url( $transcript_url ); ?>" />
571                                    <?php if ( '' !== $transcript_type ) : ?>
572                                        <meta itemprop="encodingFormat" content="<?php echo esc_attr( $transcript_type ); ?>" />
573                                    <?php endif; ?>
574                                    <a href="<?php echo esc_url( $transcript_url ); ?>" class="jetpack-podcast-episode__transcript-link">
575                                        <?php esc_html_e( 'Read transcript', 'jetpack-podcast' ); ?>
576                                    </a>
577                                </li>
578                            <?php endif; ?>
579                            <?php if ( $location_name ) : ?>
580                                <li class="jetpack-podcast-episode__location" itemprop="contentLocation"><?php echo esc_html( $location_name ); ?></li>
581                            <?php endif; ?>
582                            <?php if ( $license ) : ?>
583                                <li class="jetpack-podcast-episode__license">
584                                    <?php
585                                    /* translators: %s: license identifier (e.g. "CC-BY-4.0"). */
586                                    $license_label = sprintf( _x( 'License: %s', 'episode metadata license label', 'jetpack-podcast' ), $license );
587                                    ?>
588                                    <?php if ( $license_url ) : ?>
589                                        <a href="<?php echo esc_url( $license_url ); ?>" itemprop="license"><?php echo esc_html( $license_label ); ?></a>
590                                    <?php else : ?>
591                                        <?php echo esc_html( $license_label ); ?>
592                                    <?php endif; ?>
593                                </li>
594                            <?php endif; ?>
595                        </ul>
596                    <?php endif; ?>
597                </div>
598            </article>
599        </div>
600        <?php
601
602        return ob_get_clean();
603    }
604
605    /**
606     * Render the block for email (WooCommerce Email Editor). Email can't run the
607     * player, so render a static card linking back to the episode post.
608     *
609     * @param string $block_content     The original block HTML content.
610     * @param array  $parsed_block      The parsed block data including attributes.
611     * @param object $rendering_context Email rendering context.
612     * @return string
613     */
614    public static function render_email( $block_content, array $parsed_block, $rendering_context ) {
615        if ( ! isset( $parsed_block['attrs'] ) || ! is_array( $parsed_block['attrs'] ) ||
616            ! class_exists( '\Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper' ) ||
617            ! class_exists( '\Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper' ) ) {
618            return '';
619        }
620
621        $attrs = $parsed_block['attrs'];
622
623        if ( empty( $attrs['mediaUrl'] ) || ! wp_http_validate_url( $attrs['mediaUrl'] ) ) {
624            return '';
625        }
626
627        $post = get_post();
628        if ( ! $post ) {
629            return '';
630        }
631
632        $post_url = get_permalink( $post );
633        if ( empty( $post_url ) ) {
634            return '';
635        }
636
637        $title          = get_the_title( $post );
638        $show_author    = ! isset( $attrs['showAuthor'] ) || ! empty( $attrs['showAuthor'] );
639        $author_name    = $show_author ? get_the_author_meta( 'display_name', (int) $post->post_author ) : '';
640        $publish_date   = get_the_date( '', $post );
641        $duration       = isset( $attrs['duration'] ) ? trim( (string) $attrs['duration'] ) : '';
642        $season_number  = isset( $attrs['seasonNumber'] ) ? (int) $attrs['seasonNumber'] : 0;
643        $episode_number = isset( $attrs['episodeNumber'] ) ? (int) $attrs['episodeNumber'] : 0;
644
645        $image_url = self::resolve_cover_art_url( $attrs, $post, 'thumbnail' );
646        if ( '' !== $image_url && ! wp_http_validate_url( $image_url ) ) {
647            $image_url = '';
648        }
649
650        $cta_label = isset( $attrs['mediaType'] ) && 'video' === $attrs['mediaType']
651            ? __( 'Watch the episode', 'jetpack-podcast' )
652            : __( 'Listen to the episode', 'jetpack-podcast' );
653
654        $meta_parts = array();
655        if ( $season_number ) {
656            /* translators: %d: season number. */
657            $meta_parts[] = sprintf( __( 'Season %d', 'jetpack-podcast' ), $season_number );
658        }
659        if ( $episode_number ) {
660            /* translators: %d: episode number. */
661            $meta_parts[] = sprintf( __( 'Episode %d', 'jetpack-podcast' ), $episode_number );
662        }
663
664        $byline_parts = array_filter( array( $author_name, $publish_date, $duration ) );
665
666        $body = '';
667        if ( $meta_parts ) {
668            $body .= sprintf(
669                '<p style="margin: 0 0 4px; font-size: 12px; line-height: 1.4; text-transform: uppercase; letter-spacing: 0.5px; color: #757575;">%s</p>',
670                esc_html( implode( ' · ', $meta_parts ) )
671            );
672        }
673        if ( $title ) {
674            $body .= sprintf(
675                '<h3 style="margin: 0 0 4px; font-size: 18px; line-height: 1.3;"><a href="%s" style="color: inherit; text-decoration: none;">%s</a></h3>',
676                esc_url( $post_url ),
677                esc_html( $title )
678            );
679        }
680        if ( $byline_parts ) {
681            $body .= sprintf(
682                '<p style="margin: 0 0 12px; font-size: 13px; line-height: 1.4; color: #757575;">%s</p>',
683                esc_html( implode( ' · ', $byline_parts ) )
684            );
685        }
686        $body .= sprintf(
687            '<p style="margin: 0; font-size: 14px;"><a href="%s" style="font-weight: 600;">&#9654;&nbsp; %s</a></p>',
688            esc_url( $post_url ),
689            esc_html( $cta_label )
690        );
691
692        $cells = '';
693        if ( '' !== $image_url ) {
694            $image_link = sprintf(
695                '<a href="%s"><img src="%s" alt="" width="96" height="96" style="display: block; width: 96px; height: 96px; border-radius: 4px;" /></a>',
696                esc_url( $post_url ),
697                esc_url( $image_url )
698            );
699
700            // Padding on an inner wrapper, not the cell: the mobile media query
701            // zeroes `.layout-flex-item` padding when the card stacks.
702            // @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
703            $cells .= \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_cell(
704                '<div style="padding: 16px 0 0 16px;">' . $image_link . '</div>',
705                array(
706                    'class'  => 'layout-flex-item',
707                    'width'  => '96',
708                    'valign' => 'top',
709                    'style'  => 'width: 96px;',
710                )
711            );
712        }
713
714        // Table-wrap the body so bare <p>/<h3> survive email pipelines (core
715        // blocks do the same); padding rides the nested cell, safe when stacked.
716        // @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
717        $body_table = \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper(
718            $body,
719            array( 'style' => 'width: 100%; border-collapse: collapse;' ),
720            array( 'style' => 'padding: 16px;' )
721        );
722
723        // @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
724        $cells .= \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_cell(
725            $body_table,
726            array(
727                'class'  => 'layout-flex-item',
728                'valign' => 'top',
729            )
730        );
731
732        // Cap to the email layout width when the context exposes it.
733        $target_width = 600;
734        if ( method_exists( $rendering_context, 'get_layout_width_without_padding' ) ) {
735            // @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
736            $layout_width = (int) \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper::parse_value( $rendering_context->get_layout_width_without_padding() );
737            if ( $layout_width > 0 ) {
738                $target_width = $layout_width;
739            }
740        }
741
742        // Preserve the vertical gap from email_attrs (the engine sets margin-top).
743        $email_attrs        = $parsed_block['email_attrs'] ?? array();
744        $table_margin_style = (string) \WP_Style_Engine::compile_css( array_intersect_key( $email_attrs, array_flip( array( 'margin', 'margin-top' ) ) ), '' );
745
746        // `layout-flex-wrapper` opts into the engine's mobile stacking (collapses
747        // under 660px). border-collapse must stay `separate` for the rounded border.
748        $table_style = sprintf(
749            '%s width: 100%%; max-width: %dpx; border-collapse: separate; border: 1px solid #ddd; border-radius: 6px;',
750            $table_margin_style ? $table_margin_style : 'margin: 16px 0;',
751            $target_width
752        );
753
754        // Append user block supports so editor styling overrides the defaults.
755        // @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
756        $user_styles = \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper::get_block_styles( $attrs, $rendering_context, array( 'padding', 'border', 'background-color', 'color' ) );
757        if ( ! empty( $user_styles['css'] ) ) {
758            $table_style .= ' ' . $user_styles['css'];
759        }
760
761        // @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
762        return \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper(
763            $cells,
764            array(
765                'class' => 'jetpack-podcast-episode-email-card layout-flex-wrapper',
766                'style' => $table_style,
767            ),
768            array(),
769            array(),
770            false
771        );
772    }
773}