Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.35% covered (warning)
77.35%
362 / 468
20.00% covered (danger)
20.00%
2 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Podcast_Episode_Block
77.35% covered (warning)
77.35%
362 / 468
20.00% covered (danger)
20.00%
2 / 10
457.45
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.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     * 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        $show_author    = ! isset( $attributes['showAuthor'] ) || ! empty( $attributes['showAuthor'] );
244        $transcript_url = isset( $attributes['transcriptUrl'] ) ? esc_url_raw( $attributes['transcriptUrl'] ) : '';
245        $location_name  = isset( $attributes['locationName'] ) ? (string) $attributes['locationName'] : '';
246        $license        = isset( $attributes['license'] ) ? (string) $attributes['license'] : '';
247        $license_url    = isset( $attributes['licenseUrl'] ) ? esc_url_raw( $attributes['licenseUrl'] ) : '';
248        $people         = isset( $attributes['people'] ) && is_array( $attributes['people'] ) ? $attributes['people'] : array();
249
250        $soundbites           = isset( $attributes['soundbites'] ) && is_array( $attributes['soundbites'] ) ? $attributes['soundbites'] : array();
251        $alternate_enclosures = isset( $attributes['alternateEnclosures'] ) && is_array( $attributes['alternateEnclosures'] ) ? $attributes['alternateEnclosures'] : array();
252
253        // Drop incomplete rows up front so an all-empty list doesn't enqueue the seek script or emit a bare <ul>.
254        $soundbites           = array_values(
255            array_filter(
256                $soundbites,
257                static function ( $soundbite ) {
258                    return is_array( $soundbite ) && isset( $soundbite['startTime'] );
259                }
260            )
261        );
262        $alternate_enclosures = array_values(
263            array_filter(
264                $alternate_enclosures,
265                static function ( $alt ) {
266                    if ( ! is_array( $alt ) || empty( $alt['url'] ) ) {
267                        return false;
268                    }
269                    if ( ! wp_http_validate_url( esc_url_raw( (string) $alt['url'] ) ) ) {
270                        return false;
271                    }
272                    // `type` is required per spec โ€” mirror the feed, which skips typeless entries.
273                    return '' !== ( isset( $alt['type'] ) ? trim( (string) $alt['type'] ) : '' );
274                }
275            )
276        );
277
278        // Only ship the click-to-seek script when there are soundbites to wire.
279        if ( ! empty( $soundbites ) ) {
280            self::enqueue_view_script();
281        }
282
283        if ( '' !== $transcript_url && ! wp_http_validate_url( $transcript_url ) ) {
284            $transcript_url = '';
285        }
286
287        $author_id        = (int) $post->post_author;
288        $title            = get_the_title( $post );
289        $author_name      = $show_author ? get_the_author_meta( 'display_name', $author_id ) : '';
290        $author_url       = $show_author ? esc_url_raw( (string) get_the_author_meta( 'url', $author_id ) ) : '';
291        $publish_date_iso = get_the_date( 'c', $post );
292        $publish_date     = get_the_date( '', $post );
293        $episode_url      = get_permalink( $post );
294        $transcript_type  = isset( $attributes['transcriptType'] ) ? (string) $attributes['transcriptType'] : '';
295
296        // Show-level data backs the `partOfSeries` schema reference.
297        $show_title     = (string) get_option( 'podcasting_title', '' );
298        $show_image_url = (string) get_option( 'podcasting_image', '' );
299        $show_email     = (string) get_option( 'podcasting_email', '' );
300
301        // Resolve cover art unconditionally so schema always carries the image;
302        // `$show_poster` only gates the visible figure/poster.
303        $image_url = self::resolve_cover_art_url( $attributes, $post, 'full' );
304
305        $media_object_type = 'video' === $media_type ? 'VideoObject' : 'AudioObject';
306
307        $wrapper_attributes = get_block_wrapper_attributes();
308
309        ob_start();
310        ?>
311        <div <?php echo $wrapper_attributes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- get_block_wrapper_attributes() returns pre-escaped attribute output. ?>>
312            <article class="jetpack-podcast-episode" itemscope itemtype="https://schema.org/PodcastEpisode">
313                <?php if ( $episode_url ) : ?>
314                    <link itemprop="url" href="<?php echo esc_url( $episode_url ); ?>" />
315                <?php endif; ?>
316                <?php if ( $image_url && $show_poster ) : ?>
317                    <figure class="jetpack-podcast-episode__poster">
318                        <img
319                            src="<?php echo esc_url( $image_url ); ?>"
320                            alt=""
321                            itemprop="image"
322                            loading="lazy"
323                        />
324                    </figure>
325                <?php elseif ( $image_url ) : ?>
326                    <meta itemprop="image" content="<?php echo esc_url( $image_url ); ?>" />
327                <?php endif; ?>
328
329                <div class="jetpack-podcast-episode__body">
330                    <?php if ( $season_number || $episode_number || 'full' !== $episode_type || $is_explicit ) : ?>
331                        <p class="jetpack-podcast-episode__meta-line">
332                            <?php if ( $season_number ) : ?>
333                                <span class="jetpack-podcast-episode__season" itemprop="partOfSeason" itemscope itemtype="https://schema.org/PodcastSeason">
334                                    <meta itemprop="seasonNumber" content="<?php echo esc_attr( (string) $season_number ); ?>" />
335                                    <?php
336                                    /* translators: %d: season number. */
337                                    echo esc_html( sprintf( __( 'Season %d', 'jetpack-podcast' ), $season_number ) );
338                                    ?>
339                                </span>
340                            <?php endif; ?>
341                            <?php if ( $episode_number ) : ?>
342                                <span class="jetpack-podcast-episode__episode-number">
343                                    <meta itemprop="episodeNumber" content="<?php echo esc_attr( (string) $episode_number ); ?>" />
344                                    <?php
345                                    /* translators: %d: episode number. */
346                                    echo esc_html( sprintf( __( 'Episode %d', 'jetpack-podcast' ), $episode_number ) );
347                                    ?>
348                                </span>
349                            <?php endif; ?>
350                            <?php if ( 'trailer' === $episode_type ) : ?>
351                                <span class="jetpack-podcast-episode__badge jetpack-podcast-episode__badge--trailer"><?php esc_html_e( 'Trailer', 'jetpack-podcast' ); ?></span>
352                            <?php elseif ( 'bonus' === $episode_type ) : ?>
353                                <span class="jetpack-podcast-episode__badge jetpack-podcast-episode__badge--bonus"><?php esc_html_e( 'Bonus', 'jetpack-podcast' ); ?></span>
354                            <?php endif; ?>
355                            <?php if ( $is_explicit ) : ?>
356                                <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>
357                            <?php endif; ?>
358                        </p>
359                    <?php endif; ?>
360
361                    <?php if ( $title ) : ?>
362                        <h3 class="jetpack-podcast-episode__title" itemprop="name">
363                            <?php if ( $episode_url ) : ?>
364                                <a href="<?php echo esc_url( $episode_url ); ?>"><?php echo esc_html( $title ); ?></a>
365                            <?php else : ?>
366                                <?php echo esc_html( $title ); ?>
367                            <?php endif; ?>
368                        </h3>
369                    <?php endif; ?>
370
371                    <?php if ( $author_name || $publish_date || $duration ) : ?>
372                        <p class="jetpack-podcast-episode__byline">
373                            <?php if ( $author_name ) : ?>
374                                <span class="jetpack-podcast-episode__author" itemprop="author" itemscope itemtype="https://schema.org/Person">
375                                    <?php if ( $author_url ) : ?>
376                                        <a href="<?php echo esc_url( $author_url ); ?>" itemprop="url">
377                                            <span itemprop="name"><?php echo esc_html( $author_name ); ?></span>
378                                        </a>
379                                    <?php else : ?>
380                                        <span itemprop="name"><?php echo esc_html( $author_name ); ?></span>
381                                    <?php endif; ?>
382                                </span>
383                            <?php endif; ?>
384                            <?php if ( $publish_date ) : ?>
385                                <time
386                                    class="jetpack-podcast-episode__date"
387                                    datetime="<?php echo esc_attr( $publish_date_iso ); ?>"
388                                    itemprop="datePublished"
389                                >
390                                    <?php echo esc_html( $publish_date ); ?>
391                                </time>
392                            <?php endif; ?>
393                            <?php if ( $duration ) : ?>
394                                <span class="jetpack-podcast-episode__duration"><?php echo esc_html( $duration ); ?></span>
395                            <?php endif; ?>
396                        </p>
397                    <?php endif; ?>
398
399                    <div
400                        class="jetpack-podcast-episode__player"
401                        itemprop="<?php echo 'video' === $media_type ? 'video' : 'audio'; ?>"
402                        itemscope
403                        itemtype="https://schema.org/<?php echo esc_attr( $media_object_type ); ?>"
404                    >
405                        <meta itemprop="contentUrl" content="<?php echo esc_url( $media_url ); ?>" />
406                        <?php if ( $mime_type ) : ?>
407                            <meta itemprop="encodingFormat" content="<?php echo esc_attr( $mime_type ); ?>" />
408                        <?php endif; ?>
409                        <?php if ( $duration ) : ?>
410                            <meta itemprop="duration" content="<?php echo esc_attr( $duration ); ?>" />
411                        <?php endif; ?>
412                        <?php if ( 'video' === $media_type ) : ?>
413                            <video
414                                class="jetpack-podcast-episode__video"
415                                controls
416                                preload="none"
417                                src="<?php echo esc_url( $media_url ); ?>"
418                                <?php
419                                if ( $image_url && $show_poster ) :
420                                    ?>
421                                    poster="<?php echo esc_url( $image_url ); ?>"<?php endif; ?>
422                                <?php
423                                if ( $mime_type ) :
424                                    ?>
425                                    data-mime="<?php echo esc_attr( $mime_type ); ?>"<?php endif; ?>
426                            ><a href="<?php echo esc_url( $media_url ); ?>"><?php esc_html_e( 'Watch the episode', 'jetpack-podcast' ); ?></a></video>
427                        <?php else : ?>
428                            <audio
429                                class="jetpack-podcast-episode__audio"
430                                controls
431                                preload="none"
432                                src="<?php echo esc_url( $media_url ); ?>"
433                                <?php
434                                if ( $mime_type ) :
435                                    ?>
436                                    data-mime="<?php echo esc_attr( $mime_type ); ?>"<?php endif; ?>
437                            ><a href="<?php echo esc_url( $media_url ); ?>"><?php esc_html_e( 'Listen to the episode', 'jetpack-podcast' ); ?></a></audio>
438                        <?php endif; ?>
439                    </div>
440
441                    <?php if ( ! empty( $soundbites ) ) : ?>
442                        <ul class="jetpack-podcast-episode__soundbites">
443                            <?php
444                            foreach ( $soundbites as $soundbite ) :
445                                if ( ! is_array( $soundbite ) || ! isset( $soundbite['startTime'] ) ) {
446                                    continue;
447                                }
448                                $start_label     = self::format_seconds_label( $soundbite['startTime'] );
449                                $soundbite_title = isset( $soundbite['title'] ) ? trim( (string) $soundbite['title'] ) : '';
450                                // Keep fractional offsets so the seek button and Clip metadata match the feed.
451                                $start_seconds = max( 0, (float) $soundbite['startTime'] );
452                                $end_seconds   = isset( $soundbite['duration'] )
453                                    ? $start_seconds + max( 0, (float) $soundbite['duration'] )
454                                    : null;
455                                ?>
456                                <li
457                                    class="jetpack-podcast-episode__soundbite"
458                                    itemprop="hasPart"
459                                    itemscope
460                                    itemtype="https://schema.org/Clip"
461                                >
462                                    <meta itemprop="startOffset" content="<?php echo esc_attr( (string) $start_seconds ); ?>" />
463                                    <?php if ( null !== $end_seconds ) : ?>
464                                        <meta itemprop="endOffset" content="<?php echo esc_attr( (string) $end_seconds ); ?>" />
465                                    <?php endif; ?>
466                                    <button
467                                        type="button"
468                                        class="jetpack-podcast-episode__soundbite-button"
469                                        data-start-time="<?php echo esc_attr( (string) $start_seconds ); ?>"
470                                    >
471                                        <time class="jetpack-podcast-episode__soundbite-time"><?php echo esc_html( $start_label ); ?></time>
472                                        <?php if ( '' !== $soundbite_title ) : ?>
473                                            <span class="jetpack-podcast-episode__soundbite-title" itemprop="name"><?php echo esc_html( $soundbite_title ); ?></span>
474                                        <?php endif; ?>
475                                    </button>
476                                </li>
477                            <?php endforeach; ?>
478                        </ul>
479                    <?php endif; ?>
480
481                    <?php if ( ! empty( $alternate_enclosures ) ) : ?>
482                        <ul class="jetpack-podcast-episode__alternates">
483                            <?php
484                            foreach ( $alternate_enclosures as $alt ) :
485                                if ( ! is_array( $alt ) || empty( $alt['url'] ) ) {
486                                    continue;
487                                }
488                                $alt_url = esc_url_raw( (string) $alt['url'] );
489                                if ( ! wp_http_validate_url( $alt_url ) ) {
490                                    continue;
491                                }
492                                $alt_type    = isset( $alt['type'] ) ? (string) $alt['type'] : '';
493                                $alt_title   = isset( $alt['title'] ) ? trim( (string) $alt['title'] ) : '';
494                                $alt_lang    = isset( $alt['lang'] ) ? (string) $alt['lang'] : '';
495                                $alt_bitrate = isset( $alt['bitrate'] ) ? (int) $alt['bitrate'] : 0;
496                                $details     = array();
497                                if ( '' !== $alt_lang ) {
498                                    $details[] = $alt_lang;
499                                }
500                                if ( $alt_bitrate > 0 ) {
501                                    $details[] = sprintf(
502                                        /* translators: %d: bitrate in kilobits per second. */
503                                        __( '%d kbps', 'jetpack-podcast' ),
504                                        (int) round( $alt_bitrate / 1000 )
505                                    );
506                                }
507                                if ( '' !== $alt_type ) {
508                                    $details[] = $alt_type;
509                                }
510                                $details_label = $details ? ' (' . implode( ', ', $details ) . ')' : '';
511                                $display_label = '' !== $alt_title ? $alt_title : __( 'Alternative version', 'jetpack-podcast' );
512                                ?>
513                                <li class="jetpack-podcast-episode__alternate">
514                                    <a href="<?php echo esc_url( $alt_url ); ?>"<?php echo '' !== $alt_lang ? ' hreflang="' . esc_attr( $alt_lang ) . '"' : ''; ?>>
515                                        <?php echo esc_html( $display_label . $details_label ); ?>
516                                    </a>
517                                </li>
518                            <?php endforeach; ?>
519                        </ul>
520                    <?php endif; ?>
521
522                    <?php if ( ! empty( $people ) ) : ?>
523                        <ul class="jetpack-podcast-episode__people">
524                            <?php
525                            foreach ( $people as $person ) :
526                                if ( ! is_array( $person ) || empty( $person['name'] ) ) {
527                                    continue;
528                                }
529                                $person_name = (string) $person['name'];
530                                $person_role = isset( $person['role'] ) ? (string) $person['role'] : '';
531                                $person_href = isset( $person['href'] ) ? esc_url_raw( $person['href'] ) : '';
532                                $person_img  = isset( $person['img'] ) ? esc_url_raw( $person['img'] ) : '';
533                                ?>
534                                <li class="jetpack-podcast-episode__person" itemprop="contributor" itemscope itemtype="https://schema.org/Person">
535                                    <?php if ( $person_img ) : ?>
536                                        <img src="<?php echo esc_url( $person_img ); ?>" alt="" loading="lazy" />
537                                    <?php endif; ?>
538                                    <?php if ( $person_href ) : ?>
539                                        <a href="<?php echo esc_url( $person_href ); ?>" itemprop="url">
540                                            <span itemprop="name"><?php echo esc_html( $person_name ); ?></span>
541                                        </a>
542                                    <?php else : ?>
543                                        <span itemprop="name"><?php echo esc_html( $person_name ); ?></span>
544                                    <?php endif; ?>
545                                    <?php if ( $person_role ) : ?>
546                                        <span class="jetpack-podcast-episode__person-role"><?php echo esc_html( $person_role ); ?></span>
547                                    <?php endif; ?>
548                                </li>
549                            <?php endforeach; ?>
550                        </ul>
551                    <?php endif; ?>
552
553                    <?php if ( '' !== $show_title ) : ?>
554                        <div class="jetpack-podcast-episode__series" itemprop="partOfSeries" itemscope itemtype="https://schema.org/PodcastSeries">
555                            <meta itemprop="name" content="<?php echo esc_attr( $show_title ); ?>" />
556                            <?php if ( $show_image_url ) : ?>
557                                <meta itemprop="image" content="<?php echo esc_url( $show_image_url ); ?>" />
558                            <?php endif; ?>
559                            <?php if ( $show_email ) : ?>
560                                <span itemprop="publisher" itemscope itemtype="https://schema.org/Organization">
561                                    <meta itemprop="email" content="<?php echo esc_attr( $show_email ); ?>" />
562                                </span>
563                            <?php endif; ?>
564                        </div>
565                    <?php endif; ?>
566
567                    <?php if ( $transcript_url || $location_name || $license ) : ?>
568                        <ul class="jetpack-podcast-episode__links">
569                            <?php if ( $transcript_url ) : ?>
570                                <li itemprop="transcript" itemscope itemtype="https://schema.org/MediaObject">
571                                    <meta itemprop="contentUrl" content="<?php echo esc_url( $transcript_url ); ?>" />
572                                    <?php if ( '' !== $transcript_type ) : ?>
573                                        <meta itemprop="encodingFormat" content="<?php echo esc_attr( $transcript_type ); ?>" />
574                                    <?php endif; ?>
575                                    <a href="<?php echo esc_url( $transcript_url ); ?>" class="jetpack-podcast-episode__transcript-link">
576                                        <?php esc_html_e( 'Read transcript', 'jetpack-podcast' ); ?>
577                                    </a>
578                                </li>
579                            <?php endif; ?>
580                            <?php if ( $location_name ) : ?>
581                                <li class="jetpack-podcast-episode__location" itemprop="contentLocation"><?php echo esc_html( $location_name ); ?></li>
582                            <?php endif; ?>
583                            <?php if ( $license ) : ?>
584                                <li class="jetpack-podcast-episode__license">
585                                    <?php
586                                    /* translators: %s: license identifier (e.g. "CC-BY-4.0"). */
587                                    $license_label = sprintf( _x( 'License: %s', 'episode metadata license label', 'jetpack-podcast' ), $license );
588                                    ?>
589                                    <?php if ( $license_url ) : ?>
590                                        <a href="<?php echo esc_url( $license_url ); ?>" itemprop="license"><?php echo esc_html( $license_label ); ?></a>
591                                    <?php else : ?>
592                                        <?php echo esc_html( $license_label ); ?>
593                                    <?php endif; ?>
594                                </li>
595                            <?php endif; ?>
596                        </ul>
597                    <?php endif; ?>
598                </div>
599            </article>
600        </div>
601        <?php
602
603        return ob_get_clean();
604    }
605
606    /**
607     * Render the block for email (WooCommerce Email Editor). Email can't run the
608     * player, so render a static card linking back to the episode post.
609     *
610     * @param string $block_content     The original block HTML content.
611     * @param array  $parsed_block      The parsed block data including attributes.
612     * @param object $rendering_context Email rendering context.
613     * @return string
614     */
615    public static function render_email( $block_content, array $parsed_block, $rendering_context ) {
616        if ( ! isset( $parsed_block['attrs'] ) || ! is_array( $parsed_block['attrs'] ) ||
617            ! class_exists( '\Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper' ) ||
618            ! class_exists( '\Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper' ) ) {
619            return '';
620        }
621
622        $attrs = $parsed_block['attrs'];
623
624        if ( empty( $attrs['mediaUrl'] ) || ! wp_http_validate_url( $attrs['mediaUrl'] ) ) {
625            return '';
626        }
627
628        $post = get_post();
629        if ( ! $post ) {
630            return '';
631        }
632
633        $post_url = get_permalink( $post );
634        if ( empty( $post_url ) ) {
635            return '';
636        }
637
638        $title          = get_the_title( $post );
639        $show_author    = ! isset( $attrs['showAuthor'] ) || ! empty( $attrs['showAuthor'] );
640        $author_name    = $show_author ? get_the_author_meta( 'display_name', (int) $post->post_author ) : '';
641        $publish_date   = get_the_date( '', $post );
642        $duration       = isset( $attrs['duration'] ) ? trim( (string) $attrs['duration'] ) : '';
643        $season_number  = isset( $attrs['seasonNumber'] ) ? (int) $attrs['seasonNumber'] : 0;
644        $episode_number = isset( $attrs['episodeNumber'] ) ? (int) $attrs['episodeNumber'] : 0;
645
646        $image_url = self::resolve_cover_art_url( $attrs, $post, 'thumbnail' );
647        if ( '' !== $image_url && ! wp_http_validate_url( $image_url ) ) {
648            $image_url = '';
649        }
650
651        $cta_label = isset( $attrs['mediaType'] ) && 'video' === $attrs['mediaType']
652            ? __( 'Watch the episode', 'jetpack-podcast' )
653            : __( 'Listen to the episode', 'jetpack-podcast' );
654
655        $meta_parts = array();
656        if ( $season_number ) {
657            /* translators: %d: season number. */
658            $meta_parts[] = sprintf( __( 'Season %d', 'jetpack-podcast' ), $season_number );
659        }
660        if ( $episode_number ) {
661            /* translators: %d: episode number. */
662            $meta_parts[] = sprintf( __( 'Episode %d', 'jetpack-podcast' ), $episode_number );
663        }
664
665        $byline_parts = array_filter( array( $author_name, $publish_date, $duration ) );
666
667        $body = '';
668        if ( $meta_parts ) {
669            $body .= sprintf(
670                '<p style="margin: 0 0 4px; font-size: 12px; line-height: 1.4; text-transform: uppercase; letter-spacing: 0.5px; color: #757575;">%s</p>',
671                esc_html( implode( ' ยท ', $meta_parts ) )
672            );
673        }
674        if ( $title ) {
675            $body .= sprintf(
676                '<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>',
677                esc_url( $post_url ),
678                esc_html( $title )
679            );
680        }
681        if ( $byline_parts ) {
682            $body .= sprintf(
683                '<p style="margin: 0 0 12px; font-size: 13px; line-height: 1.4; color: #757575;">%s</p>',
684                esc_html( implode( ' ยท ', $byline_parts ) )
685            );
686        }
687        $body .= sprintf(
688            '<p style="margin: 0; font-size: 14px;"><a href="%s" style="font-weight: 600;">&#9654;&nbsp; %s</a></p>',
689            esc_url( $post_url ),
690            esc_html( $cta_label )
691        );
692
693        $cells = '';
694        if ( '' !== $image_url ) {
695            $image_link = sprintf(
696                '<a href="%s"><img src="%s" alt="" width="96" height="96" style="display: block; width: 96px; height: 96px; border-radius: 4px;" /></a>',
697                esc_url( $post_url ),
698                esc_url( $image_url )
699            );
700
701            // Padding on an inner wrapper, not the cell: the mobile media query
702            // zeroes `.layout-flex-item` padding when the card stacks.
703            // @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
704            $cells .= \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_cell(
705                '<div style="padding: 16px 0 0 16px;">' . $image_link . '</div>',
706                array(
707                    'class'  => 'layout-flex-item',
708                    'width'  => '96',
709                    'valign' => 'top',
710                    'style'  => 'width: 96px;',
711                )
712            );
713        }
714
715        // Table-wrap the body so bare <p>/<h3> survive email pipelines (core
716        // blocks do the same); padding rides the nested cell, safe when stacked.
717        // @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
718        $body_table = \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper(
719            $body,
720            array( 'style' => 'width: 100%; border-collapse: collapse;' ),
721            array( 'style' => 'padding: 16px;' )
722        );
723
724        // @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
725        $cells .= \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_cell(
726            $body_table,
727            array(
728                'class'  => 'layout-flex-item',
729                'valign' => 'top',
730            )
731        );
732
733        // Cap to the email layout width when the context exposes it.
734        $target_width = 600;
735        if ( method_exists( $rendering_context, 'get_layout_width_without_padding' ) ) {
736            // @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
737            $layout_width = (int) \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper::parse_value( $rendering_context->get_layout_width_without_padding() );
738            if ( $layout_width > 0 ) {
739                $target_width = $layout_width;
740            }
741        }
742
743        // Preserve the vertical gap from email_attrs (the engine sets margin-top).
744        $email_attrs        = $parsed_block['email_attrs'] ?? array();
745        $table_margin_style = (string) \WP_Style_Engine::compile_css( array_intersect_key( $email_attrs, array_flip( array( 'margin', 'margin-top' ) ) ), '' );
746
747        // `layout-flex-wrapper` opts into the engine's mobile stacking (collapses
748        // under 660px). border-collapse must stay `separate` for the rounded border.
749        $table_style = sprintf(
750            '%s width: 100%%; max-width: %dpx; border-collapse: separate; border: 1px solid #ddd; border-radius: 6px;',
751            $table_margin_style ? $table_margin_style : 'margin: 16px 0;',
752            $target_width
753        );
754
755        // Append user block supports so editor styling overrides the defaults.
756        // @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
757        $user_styles = \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper::get_block_styles( $attrs, $rendering_context, array( 'padding', 'border', 'background-color', 'color' ) );
758        if ( ! empty( $user_styles['css'] ) ) {
759            $table_style .= ' ' . $user_styles['css'];
760        }
761
762        // @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
763        return \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper(
764            $cells,
765            array(
766                'class' => 'jetpack-podcast-episode-email-card layout-flex-wrapper',
767                'style' => $table_style,
768            ),
769            array(),
770            array(),
771            false
772        );
773    }
774}