Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.25% covered (warning)
77.25%
360 / 466
20.00% covered (danger)
20.00%
2 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Podcast_Episode_Block
77.25% covered (warning)
77.25%
360 / 466
20.00% covered (danger)
20.00%
2 / 10
437.76
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 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 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.47% covered (warning)
85.47%
247 / 289
0.00% covered (danger)
0.00%
0 / 1
147.14
 render_email
82.08% covered (warning)
82.08%
87 / 106
0.00% covered (danger)
0.00%
0 / 1
29.89
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     * Mark the block plan-gated so the editor shows the upgrade prompt (no-op if
81     * the site has access). Standard paid-block behavior: nudge on Atomic/Simple,
82     * hidden on self-hosted Jetpack where nudges are off โ€” upsell in the dashboard.
83     */
84    public static function flag_plan_gated() {
85        if ( Podcast_Gate::has_product_access() ) {
86            return;
87        }
88
89        \Jetpack_Gutenberg::set_extension_unavailable(
90            'podcast-episode',
91            'missing_plan',
92            array(
93                'required_feature' => 'podcast-episode',
94                'required_plan'    => Podcast_Gate::get_required_plan_slug(),
95            )
96        );
97    }
98
99    /**
100     * Register + enqueue the front-end view script (wires soundbite buttons).
101     * Called from render so it only ships on pages with the block; dedups internally.
102     */
103    private static function enqueue_view_script() {
104        Assets::register_script(
105            self::VIEW_HANDLE,
106            '../../../dist/blocks/podcast-episode/view.js',
107            __FILE__,
108            array(
109                'in_footer' => true,
110                'enqueue'   => true,
111            )
112        );
113    }
114
115    /**
116     * Enqueue the bundled editor script + style from the package's dist/.
117     */
118    public static function load_editor_scripts() {
119        Assets::register_script(
120            self::EDITOR_HANDLE,
121            '../../../dist/blocks/podcast-episode/editor.js',
122            __FILE__,
123            array(
124                'in_footer'  => true,
125                'enqueue'    => true,
126                'textdomain' => 'jetpack-podcast',
127            )
128        );
129
130        // Only hook the src rewrite while the editor script is loading.
131        add_filter( 'script_loader_src', array( __CLASS__, 'filter_editor_script_src' ), 10, 2 );
132    }
133
134    /**
135     * Rewrite the editor script src to the admin scheme, avoiding a mixed-content
136     * block on WPCOM custom domains without SSL (where `plugins_url()` returns
137     * http but wp-admin is https).
138     *
139     * @param string $src    Script source URL.
140     * @param string $handle Script handle.
141     * @return string
142     */
143    public static function filter_editor_script_src( $src, $handle ) {
144        if ( self::EDITOR_HANDLE !== $handle ) {
145            return $src;
146        }
147
148        $admin_scheme = wp_parse_url( admin_url(), PHP_URL_SCHEME );
149        if ( ! $admin_scheme ) {
150            return $src;
151        }
152
153        return set_url_scheme( $src, $admin_scheme );
154    }
155
156    /**
157     * Format a seconds value as a MM:SS or HH:MM:SS label. Negative input clamps to 0.
158     *
159     * @param float|int|string $seconds Seconds value (Podcasting 2.0 soundbite startTime/duration are floats).
160     * @return string Formatted label (always non-empty; "0:00" for zero input).
161     */
162    private static function format_seconds_label( $seconds ) {
163        $total   = (int) floor( max( 0, (float) $seconds ) );
164        $hours   = (int) floor( $total / 3600 );
165        $minutes = (int) floor( ( $total % 3600 ) / 60 );
166        $secs    = $total % 60;
167
168        if ( $hours > 0 ) {
169            return sprintf( '%d:%02d:%02d', $hours, $minutes, $secs );
170        }
171        return sprintf( '%d:%02d', $minutes, $secs );
172    }
173
174    /**
175     * Resolve the episode cover art URL: episode override โ†’ post featured
176     * image โ†’ show-level `podcasting_image` option โ†’ empty string.
177     *
178     * @param array    $attributes Block attributes.
179     * @param \WP_Post $post       Episode post.
180     * @param string   $size       Featured-image size to request.
181     * @return string
182     */
183    private static function resolve_cover_art_url( array $attributes, $post, $size ) {
184        if ( isset( $attributes['coverArt'] ) && is_array( $attributes['coverArt'] ) && ! empty( $attributes['coverArt']['url'] ) ) {
185            return esc_url_raw( $attributes['coverArt']['url'] );
186        }
187
188        $featured_id = (int) get_post_thumbnail_id( $post );
189        if ( $featured_id ) {
190            $featured_url = (string) wp_get_attachment_image_url( $featured_id, $size );
191            if ( '' !== $featured_url ) {
192                return $featured_url;
193            }
194        }
195
196        return (string) get_option( 'podcasting_image', '' );
197    }
198
199    /**
200     * Render callback. Full player in every context so the RSS feed carries it
201     * (how the WPCOM Reader shows it on Atomic/Jetpack). Email uses render_email().
202     *
203     * @param array     $attributes Block attributes.
204     * @param string    $content    Saved inner content; unused, the block renders its own markup.
205     * @param \WP_Block $block      Parsed block instance, used for post context.
206     * @return string
207     */
208    public static function render_block( $attributes, $content, $block = null ) {
209        if ( empty( $attributes['mediaUrl'] ) ) {
210            return '';
211        }
212
213        // Resolve the backing post: block context (Query Loop / singular) first,
214        // then the global loop.
215        $post_id = 0;
216        if ( $block && isset( $block->context['postId'] ) ) {
217            $post_id = (int) $block->context['postId'];
218        }
219        if ( ! $post_id ) {
220            $post_id = (int) get_the_ID();
221        }
222        if ( ! $post_id ) {
223            return '';
224        }
225        $post = get_post( $post_id );
226        if ( ! $post ) {
227            return '';
228        }
229
230        $media_url = esc_url_raw( $attributes['mediaUrl'] );
231        if ( ! wp_http_validate_url( $media_url ) ) {
232            return '';
233        }
234
235        $media_type     = isset( $attributes['mediaType'] ) && 'video' === $attributes['mediaType'] ? 'video' : 'audio';
236        $mime_type      = isset( $attributes['mediaMimeType'] ) ? (string) $attributes['mediaMimeType'] : '';
237        $episode_number = isset( $attributes['episodeNumber'] ) ? (int) $attributes['episodeNumber'] : 0;
238        $season_number  = isset( $attributes['seasonNumber'] ) ? (int) $attributes['seasonNumber'] : 0;
239        $episode_type   = isset( $attributes['episodeType'] ) ? (string) $attributes['episodeType'] : 'full';
240        $is_explicit    = ! empty( $attributes['explicit'] );
241        $duration       = isset( $attributes['duration'] ) ? (string) $attributes['duration'] : '';
242        $show_poster    = ! isset( $attributes['showPoster'] ) || ! empty( $attributes['showPoster'] );
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      = get_the_author_meta( 'display_name', $author_id );
289        $author_url       = 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        $author_name    = get_the_author_meta( 'display_name', (int) $post->post_author );
639        $publish_date   = get_the_date( '', $post );
640        $duration       = isset( $attrs['duration'] ) ? trim( (string) $attrs['duration'] ) : '';
641        $season_number  = isset( $attrs['seasonNumber'] ) ? (int) $attrs['seasonNumber'] : 0;
642        $episode_number = isset( $attrs['episodeNumber'] ) ? (int) $attrs['episodeNumber'] : 0;
643
644        $image_url = self::resolve_cover_art_url( $attrs, $post, 'thumbnail' );
645        if ( '' !== $image_url && ! wp_http_validate_url( $image_url ) ) {
646            $image_url = '';
647        }
648
649        $cta_label = isset( $attrs['mediaType'] ) && 'video' === $attrs['mediaType']
650            ? __( 'Watch the episode', 'jetpack-podcast' )
651            : __( 'Listen to the episode', 'jetpack-podcast' );
652
653        $meta_parts = array();
654        if ( $season_number ) {
655            /* translators: %d: season number. */
656            $meta_parts[] = sprintf( __( 'Season %d', 'jetpack-podcast' ), $season_number );
657        }
658        if ( $episode_number ) {
659            /* translators: %d: episode number. */
660            $meta_parts[] = sprintf( __( 'Episode %d', 'jetpack-podcast' ), $episode_number );
661        }
662
663        $byline_parts = array_filter( array( $author_name, $publish_date, $duration ) );
664
665        $body = '';
666        if ( $meta_parts ) {
667            $body .= sprintf(
668                '<p style="margin: 0 0 4px; font-size: 12px; line-height: 1.4; text-transform: uppercase; letter-spacing: 0.5px; color: #757575;">%s</p>',
669                esc_html( implode( ' ยท ', $meta_parts ) )
670            );
671        }
672        if ( $title ) {
673            $body .= sprintf(
674                '<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>',
675                esc_url( $post_url ),
676                esc_html( $title )
677            );
678        }
679        if ( $byline_parts ) {
680            $body .= sprintf(
681                '<p style="margin: 0 0 12px; font-size: 13px; line-height: 1.4; color: #757575;">%s</p>',
682                esc_html( implode( ' ยท ', $byline_parts ) )
683            );
684        }
685        $body .= sprintf(
686            '<p style="margin: 0; font-size: 14px;"><a href="%s" style="font-weight: 600;">&#9654;&nbsp; %s</a></p>',
687            esc_url( $post_url ),
688            esc_html( $cta_label )
689        );
690
691        $cells = '';
692        if ( '' !== $image_url ) {
693            $image_link = sprintf(
694                '<a href="%s"><img src="%s" alt="" width="96" height="96" style="display: block; width: 96px; height: 96px; border-radius: 4px;" /></a>',
695                esc_url( $post_url ),
696                esc_url( $image_url )
697            );
698
699            // Padding on an inner wrapper, not the cell: the mobile media query
700            // zeroes `.layout-flex-item` padding when the card stacks.
701            // @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
702            $cells .= \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_cell(
703                '<div style="padding: 16px 0 0 16px;">' . $image_link . '</div>',
704                array(
705                    'class'  => 'layout-flex-item',
706                    'width'  => '96',
707                    'valign' => 'top',
708                    'style'  => 'width: 96px;',
709                )
710            );
711        }
712
713        // Table-wrap the body so bare <p>/<h3> survive email pipelines (core
714        // blocks do the same); padding rides the nested cell, safe when stacked.
715        // @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
716        $body_table = \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper(
717            $body,
718            array( 'style' => 'width: 100%; border-collapse: collapse;' ),
719            array( 'style' => 'padding: 16px;' )
720        );
721
722        // @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
723        $cells .= \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_cell(
724            $body_table,
725            array(
726                'class'  => 'layout-flex-item',
727                'valign' => 'top',
728            )
729        );
730
731        // Cap to the email layout width when the context exposes it.
732        $target_width = 600;
733        if ( method_exists( $rendering_context, 'get_layout_width_without_padding' ) ) {
734            // @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
735            $layout_width = (int) \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper::parse_value( $rendering_context->get_layout_width_without_padding() );
736            if ( $layout_width > 0 ) {
737                $target_width = $layout_width;
738            }
739        }
740
741        // Preserve the vertical gap from email_attrs (the engine sets margin-top).
742        $email_attrs        = $parsed_block['email_attrs'] ?? array();
743        $table_margin_style = (string) \WP_Style_Engine::compile_css( array_intersect_key( $email_attrs, array_flip( array( 'margin', 'margin-top' ) ) ), '' );
744
745        // `layout-flex-wrapper` opts into the engine's mobile stacking (collapses
746        // under 660px). border-collapse must stay `separate` for the rounded border.
747        $table_style = sprintf(
748            '%s width: 100%%; max-width: %dpx; border-collapse: separate; border: 1px solid #ddd; border-radius: 6px;',
749            $table_margin_style ? $table_margin_style : 'margin: 16px 0;',
750            $target_width
751        );
752
753        // Append user block supports so editor styling overrides the defaults.
754        // @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
755        $user_styles = \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper::get_block_styles( $attrs, $rendering_context, array( 'padding', 'border', 'background-color', 'color' ) );
756        if ( ! empty( $user_styles['css'] ) ) {
757            $table_style .= ' ' . $user_styles['css'];
758        }
759
760        // @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
761        return \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper(
762            $cells,
763            array(
764                'class' => 'jetpack-podcast-episode-email-card layout-flex-wrapper',
765                'style' => $table_style,
766            ),
767            array(),
768            array(),
769            false
770        );
771    }
772}