Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 786
0.00% covered (danger)
0.00%
0 / 51
CRAP
0.00% covered (danger)
0.00%
0 / 1
Newspack_Blocks
0.00% covered (danger)
0.00%
0 / 785
0.00% covered (danger)
0.00%
0 / 51
80940
0.00% covered (danger)
0.00%
0 / 1
 init
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 hide_post_content_when_iframe_block_is_fullscreen
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
56
 add_body_classes
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 script_enqueue_helper
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 enqueue_placeholder_blocks_assets
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
12
 get_custom_taxonomies
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
6
 can_use_name_your_price
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 enqueue_block_assets
0.00% covered (danger)
0.00%
0 / 53
0.00% covered (danger)
0.00%
0 / 1
72
 manage_view_scripts
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
56
 enqueue_block_styles_assets
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 enqueue_view_assets
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
30
 block_classes
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
42
 block_styles
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
42
 image_size_for_orientation
0.00% covered (danger)
0.00%
0 / 77
0.00% covered (danger)
0.00%
0 / 1
42
 add_image_sizes
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
2
 maybe_skip_article_block_image_subsizes
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 is_wpcom_image_cdn_active
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 should_deduplicate_block
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_specific_posts_from_blocks
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
56
 build_articles_query
0.00% covered (danger)
0.00%
0 / 113
0.00% covered (danger)
0.00%
0 / 1
3540
 template_inc
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 prepare_authors
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
20
 get_term_classes
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
182
 get_patterns_for_post_type
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
56
 get_all_sponsors
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 get_sponsor_label
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 get_sponsor_byline
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
42
 get_sponsor_logos
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
42
 newspack_display_sponsors_and_authors
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 newspack_display_sponsors_and_categories
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 get_tag_labels
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 display_tag_labels
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
 remove_wc_memberships_excerpt_limit
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 filter_excerpt
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
72
 remove_excerpt_filter
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 filter_excerpt_length
n/a
0 / 0
n/a
0 / 0
4
 remove_excerpt_length_filter
n/a
0 / 0
n/a
0 / 0
2
 more_excerpt
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 filter_excerpt_more
n/a
0 / 0
n/a
0 / 0
2
 remove_excerpt_more_filter
n/a
0 / 0
n/a
0 / 0
1
 get_post_link
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
42
 sanitize_svg
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
2
 disable_jetpack_donate
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 template_include
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 get_post_status_label
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 get_color_for_contrast
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 get_apca_luminance
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
 get_apca_contrast
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 get_sanitized_image_attributes
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
2
 get_displayed_post_date
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 get_datetime_post_date
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 get_formatted_displayed_post_date
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 get_article_meta_footer
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 get_formatted_amount
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
272
 get_image_caption
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
90
1<?php
2/**
3 * Newspack blocks functionality
4 *
5 * @package Newspack_Blocks
6 */
7
8/**
9 * Newspack blocks functionality
10 */
11class Newspack_Blocks {
12
13    /**
14     * Script handles.
15     */
16    const SCRIPT_HANDLES = [
17        'modal-checkout'       => 'newspack-blocks-donate-modal-checkout',
18        'modal-checkout-block' => 'newspack-blocks-donate-modal-checkout-block',
19        'frequency-based'      => 'newspack-blocks-donate-frequency-based',
20        'tiers-based'          => 'newspack-blocks-donate-tiers-based',
21    ];
22
23    /**
24     * Add hooks and filters.
25     */
26    public static function init() {
27        add_action( 'after_setup_theme', [ __CLASS__, 'add_image_sizes' ] );
28        add_filter( 'intermediate_image_sizes_advanced', [ __CLASS__, 'maybe_skip_article_block_image_subsizes' ] );
29        add_post_type_support( 'post', 'newspack_blocks' );
30        add_post_type_support( 'page', 'newspack_blocks' );
31        add_action( 'jetpack_register_gutenberg_extensions', [ __CLASS__, 'disable_jetpack_donate' ], 99 );
32        add_filter( 'the_content', [ __CLASS__, 'hide_post_content_when_iframe_block_is_fullscreen' ] );
33        add_filter( 'body_class', [ __CLASS__, 'add_body_classes' ] );
34        add_filter( 'admin_body_class', [ __CLASS__, 'add_body_classes' ] );
35
36        /**
37         * Disable NextGEN's `C_NextGen_Shortcode_Manager`.
38         *
39         * The way it currently parses `the_content` conflicts with the REST API
40         * request to save a post containing a Homepage Posts block. This is due to
41         * how it uses output buffering through `ob_start()` on REST requests.
42         *
43         * @link https://plugins.trac.wordpress.org/browser/nextgen-gallery/tags/3.23/non_pope/class.nextgen_shortcode_manager.php#L193.
44         */
45        if ( ! defined( 'NGG_DISABLE_SHORTCODE_MANAGER' ) ) {
46            define( 'NGG_DISABLE_SHORTCODE_MANAGER', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound
47        }
48    }
49
50    /**
51     * Hide the post content when it contains an iframe block that is set to fullscreen mode.
52     *
53     * @param string $content post content from the_content hook.
54     * @return string the post content.
55     */
56    public static function hide_post_content_when_iframe_block_is_fullscreen( $content ) {
57        if ( has_block( 'newspack-blocks/iframe' ) ) {
58            $blocks = parse_blocks( get_post()->post_content );
59
60            foreach ( $blocks as $block ) {
61                if ( 'newspack-blocks/iframe' === $block['blockName']
62                    && is_array( $block['attrs'] )
63                    && array_key_exists( 'isFullScreen', $block['attrs'] )
64                    && $block['attrs']['isFullScreen']
65                    ) {
66                    // we don't need the post content since the iframe will be fullscreen.
67                    $content = render_block( $block );
68
69                    add_filter(
70                        'body_class',
71                        function( $classes ) {
72                            $classes[] = 'newspack-post-with-fullscreen-iframe';
73                            return $classes;
74                        }
75                    );
76
77                    // we don't need to show Newspack popups since the iframe will take over them.
78                    add_filter( 'newspack_popups_assess_has_disabled_popups', '__return_true' );
79                }
80            }
81        }
82
83        return $content;
84    }
85
86    /**
87     * Body class.
88     *
89     * @param string|array $classes Array or string of body class names.
90     * @return string|array Modified array or string of body class names.
91     */
92    public static function add_body_classes( $classes ) {
93        if ( wp_is_block_theme() ) {
94            // Handle string (admin) vs array (frontend) cases.
95            if ( is_string( $classes ) ) {
96                $classes .= ' is-block-theme ';
97            } else {
98                $classes[] = 'is-block-theme';
99            }
100        }
101
102        return $classes;
103    }
104
105    /**
106     * Gather dependencies and paths needed for script enqueuing.
107     *
108     * @param string $script_path Path to the script relative to plugin root.
109     *
110     * @return array Associative array including dependency array, version, and web path to the script. Returns false if script doesn't exist.
111     */
112    public static function script_enqueue_helper( $script_path ) {
113        $local_path = NEWSPACK_BLOCKS__PLUGIN_DIR . $script_path;
114        if ( ! file_exists( $local_path ) ) {
115            return false;
116        }
117
118        $path_info   = pathinfo( $local_path );
119        $asset_path  = $path_info['dirname'] . '/' . $path_info['filename'] . '.asset.php';
120        $script_data = file_exists( $asset_path )
121            ? require $asset_path
122            : array(
123                'dependencies' => [ 'wp-a11y', 'wp-escape-html', 'wp-i18n', 'wp-polyfill' ],
124                'version'      => filemtime( $local_path ),
125            );
126
127        $script_data['script_path'] = plugins_url( $script_path, NEWSPACK_BLOCKS__PLUGIN_FILE );
128        return $script_data;
129    }
130
131    /**
132     * Enqueue placeholder blocks assets.
133     */
134    public static function enqueue_placeholder_blocks_assets() {
135        if ( ! is_admin() ) {
136            // In non-editor environment, do nothing.
137            return;
138        }
139        $script_data = self::script_enqueue_helper( NEWSPACK_BLOCKS__BLOCKS_DIRECTORY . 'placeholder_blocks.js' );
140        if ( $script_data ) {
141            wp_enqueue_script(
142                'newspack-blocks-placeholder-blocks',
143                $script_data['script_path'],
144                $script_data['dependencies'],
145                $script_data['version'],
146                true
147            );
148            wp_set_script_translations(
149                'newspack-blocks-placeholder-blocks',
150                'jetpack-mu-wpcom',
151                plugin_dir_path( NEWSPACK_BLOCKS__PLUGIN_FILE ) . 'languages'
152            );
153        }
154    }
155
156    /**
157     * Gets the list of custom taxonomies that will be available for filtering in the blocks
158     *
159     * @return array Array of custom taxonomies where each taxonomy is an array with slug and label keys.
160     */
161    public static function get_custom_taxonomies() {
162        $custom_taxonomies = array_map(
163            function( $tax ) {
164                if ( ! empty( array_intersect( [ 'post', 'page' ], $tax->object_type ) ) ) {
165                    return [
166                        'slug'  => $tax->name,
167                        'label' => $tax->label,
168                    ];
169                }
170            },
171            get_taxonomies(
172                [
173                    'public'       => true,
174                    '_builtin'     => false,
175                    'show_in_rest' => true,
176                ],
177                'objects'
178            )
179        );
180        $custom_taxonomies = array_values(
181            array_filter(
182                $custom_taxonomies,
183                function( $tax ) {
184                    return ! empty( $tax );
185                }
186            )
187        );
188
189        /**
190         * Filters the custom taxonomies that will be available in the Home Page block.
191         *
192         * By default, on the top of category and tags, will display any public taxonomy applied to post or pages
193         *
194         * @param array $custom_taxonomies Array of custom taxonomies where each taxonomy is an array with slug and label keys.
195         */
196        return apply_filters( 'newspack_blocks_home_page_block_custom_taxonomies', $custom_taxonomies );
197    }
198
199    /**
200     * Check if the Name Your Price extension is available.
201     *
202     * @return bool True if available, false if not.
203     */
204    public static function can_use_name_your_price() {
205        // If the donation platform is NRH, the Donate block should behave as if Name Your Price is available.
206        if ( method_exists( 'Newspack\Donations', 'is_platform_nrh' ) && \Newspack\Donations::is_platform_nrh() ) {
207            return true;
208        }
209        return class_exists( 'WC_Name_Your_Price_Helpers' );
210    }
211
212    /**
213     * Enqueue block scripts and styles for editor.
214     */
215    public static function enqueue_block_assets() {
216        if ( ! is_admin() ) {
217            // In non-editor environment, do nothing.
218            return;
219        }
220        $script_data = static::script_enqueue_helper( NEWSPACK_BLOCKS__BLOCKS_DIRECTORY . 'editor.js' );
221        if ( $script_data ) {
222            wp_enqueue_script(
223                'newspack-blocks-editor',
224                $script_data['script_path'],
225                $script_data['dependencies'],
226                $script_data['version'],
227                true
228            );
229
230            $localized_data = [
231                'patterns'                   => self::get_patterns_for_post_type( get_post_type() ),
232                'posts_rest_url'             => rest_url( 'newspack-blocks/v1/newspack-blocks-posts' ),
233                'specific_posts_rest_url'    => rest_url( 'newspack-blocks/v1/newspack-blocks-specific-posts' ),
234                'authors_rest_url'           => rest_url( 'newspack-blocks/v1/authors' ),
235                'assets_path'                => plugins_url( '/src/assets', NEWSPACK_BLOCKS__PLUGIN_FILE ),
236                'post_subtitle'              => get_theme_support( 'post-subtitle' ),
237                'iframe_accepted_file_mimes' => WP_REST_Newspack_Iframe_Controller::iframe_accepted_file_mimes(),
238                'iframe_can_upload_archives' => WP_REST_Newspack_Iframe_Controller::can_upload_archives(),
239                'supports_recaptcha'         => class_exists( 'Newspack\Recaptcha' ),
240                'has_recaptcha'              => class_exists( 'Newspack\Recaptcha' ) && \Newspack\Recaptcha::can_use_captcha(),
241                'recaptcha_url'              => admin_url( 'admin.php?page=newspack-settings' ),
242                'custom_taxonomies'          => self::get_custom_taxonomies(),
243                'can_use_name_your_price'    => self::can_use_name_your_price(),
244                'coupons_enabled'            => function_exists( 'wc_coupons_enabled' ) && \wc_coupons_enabled(),
245                'tier_amounts_template'      => self::get_formatted_amount(),
246                'currency'                   => function_exists( 'get_woocommerce_currency' ) ? \get_woocommerce_currency() : 'USD',
247            ];
248
249            if ( class_exists( 'WP_REST_Newspack_Author_List_Controller' ) ) {
250                $localized_data['can_use_cap']    = class_exists( 'CoAuthors_Guest_Authors' );
251                $localized_data['editable_roles'] = Newspack_Blocks\get_authors_roles();
252            }
253
254            if ( class_exists( '\Newspack\Authors_Custom_Fields' ) ) {
255                $localized_data['author_custom_fields'] = \Newspack\Authors_Custom_Fields::get_custom_fields();
256            }
257
258            wp_localize_script(
259                'newspack-blocks-editor',
260                'newspack_blocks_data',
261                $localized_data
262            );
263
264            wp_set_script_translations(
265                'newspack-blocks-editor',
266                'jetpack-mu-wpcom',
267                plugin_dir_path( NEWSPACK_BLOCKS__PLUGIN_FILE ) . 'languages'
268            );
269        }
270
271        $editor_style = plugins_url( NEWSPACK_BLOCKS__BLOCKS_DIRECTORY . 'editor.css', NEWSPACK_BLOCKS__PLUGIN_FILE );
272        $handle = 'newspack-blocks-editor';
273        wp_enqueue_style(
274            $handle,
275            $editor_style,
276            array(),
277            NEWSPACK_BLOCKS__VERSION
278        );
279        wp_style_add_data( $handle, 'rtl', 'replace' );
280    }
281
282    /**
283     * Enqueue block scripts and styles for view.
284     */
285    public static function manage_view_scripts() {
286        $src_directory  = NEWSPACK_BLOCKS__PLUGIN_DIR . 'src/blocks/';
287        $dist_directory = NEWSPACK_BLOCKS__PLUGIN_DIR . 'dist/';
288        $iterator       = new DirectoryIterator( $src_directory );
289        foreach ( $iterator as $block_directory ) {
290            if ( ! $block_directory->isDir() || $block_directory->isDot() ) {
291                continue;
292            }
293            $type = $block_directory->getFilename();
294
295            /* If view.php is found, include it and use for block rendering. */
296            $view_php_path = $src_directory . $type . '/view.php';
297
298            if ( file_exists( $view_php_path ) ) {
299                include_once $view_php_path;
300                continue;
301            }
302
303            // Skip remaining logic in admin - only needed for frontend asset loading.
304            if ( is_admin() ) {
305                continue;
306            }
307
308            /* If view.php is missing but view Javascript file is found, do generic view asset loading. */
309            $view_js_path = $dist_directory . $type . '/view.js';
310            if ( file_exists( $view_js_path ) ) {
311                register_block_type(
312                    "newspack-blocks/{$type}",
313                    array(
314                        'render_callback' => function( $attributes, $content ) use ( $type ) {
315                            self::enqueue_view_assets( $type );
316                            return $content;
317                        },
318                    )
319                );
320            }
321        }
322    }
323
324    /**
325     * Enqueue block styles stylesheet.
326     */
327    public static function enqueue_block_styles_assets() {
328        $style_path = NEWSPACK_BLOCKS__BLOCKS_DIRECTORY . 'block_styles.css';
329        if ( file_exists( NEWSPACK_BLOCKS__PLUGIN_DIR . $style_path ) ) {
330            $handle = 'newspack-blocks-block-styles-stylesheet';
331            wp_enqueue_style(
332                $handle,
333                plugins_url( $style_path, NEWSPACK_BLOCKS__PLUGIN_FILE ),
334                array(),
335                NEWSPACK_BLOCKS__VERSION
336            );
337            wp_style_add_data( $handle, 'rtl', 'replace' );
338        }
339    }
340
341    /**
342     * Enqueue view scripts and styles for a single block.
343     *
344     * @param string      $type     The block's type.
345     * @param string|null $strategy Optional. Script loading strategy to apply to the
346     *                              view script ('defer' or 'async'). First write wins:
347     *                              ignored if a strategy is already set on the handle.
348     *                              Default null (no strategy).
349     */
350    public static function enqueue_view_assets( $type, $strategy = null ) {
351        $style_path = apply_filters(
352            'newspack_blocks_enqueue_view_assets',
353            NEWSPACK_BLOCKS__BLOCKS_DIRECTORY . $type . '/view.css',
354            $type,
355            is_rtl()
356        );
357
358        if ( file_exists( NEWSPACK_BLOCKS__PLUGIN_DIR . $style_path ) ) {
359            $handle = "newspack-blocks-{$type}";
360            wp_enqueue_style(
361                $handle,
362                plugins_url( $style_path, NEWSPACK_BLOCKS__PLUGIN_FILE ),
363                array(),
364                NEWSPACK_BLOCKS__VERSION
365            );
366            wp_style_add_data( $handle, 'rtl', 'replace' );
367        }
368        $script_data = static::script_enqueue_helper( NEWSPACK_BLOCKS__BLOCKS_DIRECTORY . $type . '/view.js' );
369        if ( $script_data ) {
370            $handle = "newspack-blocks-{$type}";
371            wp_enqueue_script(
372                $handle,
373                $script_data['script_path'],
374                $script_data['dependencies'],
375                $script_data['version'],
376                true
377            );
378            if ( $strategy && ! wp_scripts()->get_data( $handle, 'strategy' ) ) {
379                wp_script_add_data( $handle, 'strategy', $strategy );
380            }
381        }
382    }
383
384    /**
385     * Utility to assemble the class for a server-side rendered block.
386     *
387     * @param string $type The block type.
388     * @param array  $attributes Block attributes.
389     * @param array  $extra Additional classes to be added to the class list.
390     *
391     * @return string Class list separated by spaces.
392     */
393    public static function block_classes( $type, $attributes = array(), $extra = array() ) {
394        $classes = [ "wp-block-newspack-blocks-{$type}" ];
395
396        if ( ! empty( $attributes['align'] ) ) {
397            $classes[] = 'align' . $attributes['align'];
398        }
399        if ( ! empty( $attributes['hideControls'] ) ) {
400            $classes[] = 'hide-controls';
401        }
402        if ( isset( $attributes['className'] ) ) {
403            array_push( $classes, $attributes['className'] );
404        }
405        if ( is_array( $extra ) && ! empty( $extra ) ) {
406            $classes = array_merge( $classes, $extra );
407        }
408
409        return implode( ' ', array_filter( $classes, 'strlen' ) );
410    }
411
412    /**
413     * Utility to assemble the styles for a server-side rendered block.
414     *
415     * @param array $attributes Block attributes.
416     * @param array $extra      Additional styles to be added to the style list.
417     *
418     * @return string style list.
419     */
420    public static function block_styles( $attributes = [], $extra = [] ) {
421        $styles = [];
422        if ( isset( $attributes['style'] ) && is_array( $attributes['style'] ) ) {
423            $engine_styles = wp_style_engine_get_styles( $attributes['style'], [ 'context' => 'block-supports' ] );
424            if ( isset( $engine_styles['css'] ) ) {
425                $styles[] = $engine_styles['css'];
426            }
427        }
428
429        if ( is_array( $extra ) && ! empty( $extra ) ) {
430            $styles = array_merge( $styles, $extra );
431        }
432
433        return implode( '', $styles );
434    }
435
436    /**
437     * Return the most appropriate thumbnail size to display.
438     *
439     * @param string $orientation The block's orientation settings: landscape|portrait|square.
440     *
441     * @return string Returns the thumbnail key to use.
442     */
443    public static function image_size_for_orientation( $orientation = 'landscape' ) {
444        $sizes = array(
445            'landscape' => array(
446                'large'        => array(
447                    1200,
448                    900,
449                ),
450                'medium'       => array(
451                    800,
452                    600,
453                ),
454                'intermediate' => array(
455                    600,
456                    450,
457                ),
458                'small'        => array(
459                    400,
460                    300,
461                ),
462                'tiny'         => array(
463                    200,
464                    150,
465                ),
466            ),
467            'portrait'  => array(
468                'large'        => array(
469                    900,
470                    1200,
471                ),
472                'medium'       => array(
473                    600,
474                    800,
475                ),
476                'intermediate' => array(
477                    450,
478                    600,
479                ),
480                'small'        => array(
481                    300,
482                    400,
483                ),
484                'tiny'         => array(
485                    150,
486                    200,
487                ),
488            ),
489            'square'    => array(
490                'large'        => array(
491                    1200,
492                    1200,
493                ),
494                'medium'       => array(
495                    800,
496                    800,
497                ),
498                'intermediate' => array(
499                    600,
500                    600,
501                ),
502                'small'        => array(
503                    400,
504                    400,
505                ),
506                'tiny'         => array(
507                    200,
508                    200,
509                ),
510            ),
511        );
512
513        if ( isset( $sizes[ $orientation ] ) ) {
514            foreach ( $sizes[ $orientation ] as $key => $dimensions ) {
515                $attachment = wp_get_attachment_image_src(
516                    get_post_thumbnail_id( get_the_ID() ),
517                    'newspack-article-block-' . $orientation . '-' . $key
518                );
519                if ( ! empty( $attachment ) && $dimensions[0] === $attachment[1] && $dimensions[1] === $attachment[2] ) {
520                    return 'newspack-article-block-' . $orientation . '-' . $key;
521                }
522            }
523        }
524
525        return 'large';
526    }
527
528    /**
529     * Registers image sizes required for Newspack Blocks.
530     */
531    public static function add_image_sizes() {
532        add_image_size( 'newspack-article-block-landscape-large', 1200, 900, true );
533        add_image_size( 'newspack-article-block-portrait-large', 900, 1200, true );
534        add_image_size( 'newspack-article-block-square-large', 1200, 1200, true );
535
536        add_image_size( 'newspack-article-block-landscape-medium', 800, 600, true );
537        add_image_size( 'newspack-article-block-portrait-medium', 600, 800, true );
538        add_image_size( 'newspack-article-block-square-medium', 800, 800, true );
539
540        add_image_size( 'newspack-article-block-landscape-intermediate', 600, 450, true );
541        add_image_size( 'newspack-article-block-portrait-intermediate', 450, 600, true );
542        add_image_size( 'newspack-article-block-square-intermediate', 600, 600, true );
543
544        add_image_size( 'newspack-article-block-landscape-small', 400, 300, true );
545        add_image_size( 'newspack-article-block-portrait-small', 300, 400, true );
546        add_image_size( 'newspack-article-block-square-small', 400, 400, true );
547
548        add_image_size( 'newspack-article-block-landscape-tiny', 200, 150, true );
549        add_image_size( 'newspack-article-block-portrait-tiny', 150, 200, true );
550        add_image_size( 'newspack-article-block-square-tiny', 200, 200, true );
551
552        add_image_size( 'newspack-article-block-uncropped', 1200, 9999, false );
553    }
554
555    /**
556     * Skip generating the physical `newspack-article-block-*` sub-size files on upload.
557     *
558     * The sizes stay registered, so blocks still resolve correctly-cropped URLs;
559     * we only skip writing the files where an on-the-fly image CDN can reproduce them
560     * from the registered sizes. The prefix match removes every `newspack-article-block-*`
561     * sub-size, including `newspack-article-block-uncropped` (registered with
562     * `crop => false` â€” a plain downscale, not a crop); the CDN resizes as well as
563     * crops, so none of them need a physical file. This also makes the Image block
564     * (REST) and Media Library upload paths behave the same on wpcom, where they
565     * otherwise differ.
566     *
567     * @param array $sizes Image sub-sizes to generate, keyed by size name.
568     * @return array Filtered sizes.
569     */
570    public static function maybe_skip_article_block_image_subsizes( array $sizes ): array {
571        /**
572         * Filters whether to skip physical `newspack-article-block-*` sub-size generation.
573         * Defaults to true where an on-the-fly image CDN reproduces the sizes: WordPress.com
574         * Simple (always) and Atomic (only when the Jetpack Image CDN is active). Self-hosted
575         * sites can opt in, e.g. when fronting uploads with the Jetpack Image CDN.
576         *
577         * @param bool $skip Whether to skip physical sub-size generation.
578         */
579        $skip = apply_filters( 'newspack_blocks_skip_article_image_subsizes', self::is_wpcom_image_cdn_active() );
580        if ( ! $skip ) {
581            return $sizes;
582        }
583
584        foreach ( array_keys( $sizes ) as $size_name ) {
585            if ( is_string( $size_name ) && str_starts_with( $size_name, 'newspack-article-block-' ) ) {
586                unset( $sizes[ $size_name ] );
587            }
588        }
589        return $sizes;
590    }
591
592    /**
593     * Whether this site serves images through an on-the-fly image CDN that crops and
594     * resizes from the registered sizes, making the physical `newspack-article-block-*`
595     * files redundant.
596     *
597     * WordPress.com Simple always serves images through the platform image CDN. On
598     * Atomic the Jetpack Image CDN (Photon) can be toggled off, so it counts only when
599     * the module is active â€” otherwise the crops must still be generated. Self-hosted
600     * sites are not auto-detected here; they opt in via the filter above.
601     *
602     * @return bool
603     */
604    private static function is_wpcom_image_cdn_active(): bool {
605        if ( ! class_exists( '\Automattic\Jetpack\Status\Host' ) ) {
606            return false;
607        }
608        $host = new \Automattic\Jetpack\Status\Host();
609        if ( $host->is_wpcom_simple() ) {
610            return true;
611        }
612        if ( $host->is_wpcom_platform() ) {
613            // Atomic: only skip when the Image CDN (Photon) is actually active to crop on the fly.
614            return class_exists( 'Jetpack' ) && \Jetpack::is_module_active( 'photon' );
615        }
616        return false;
617    }
618
619    /**
620     * Whether the block should be included in the deduplication logic.
621     *
622     * @param array $attributes Block attributes.
623     *
624     * @return bool
625     */
626    public static function should_deduplicate_block( $attributes ) {
627        /**
628         * Filters whether to use deduplication while rendering the given block.
629         *
630         * @param bool   $deduplicate Whether to deduplicate.
631         * @param array  $attributes  The block attributes.
632         */
633        return apply_filters( 'newspack_blocks_should_deduplicate', $attributes['deduplicate'] ?? true, $attributes );
634    }
635
636    /**
637     * Get all "specificPosts" ids from given blocks.
638     *
639     * @param array  $blocks     An array of blocks.
640     * @param string $block_name Name of the block requesting the query.
641     *
642     * @return array All "specificPosts" ids from all eligible blocks.
643     */
644    private static function get_specific_posts_from_blocks( $blocks, $block_name ) {
645        $specific_posts = [];
646        foreach ( $blocks as $block ) {
647            if ( ! empty( $block['innerBlocks'] ) ) {
648                $specific_posts = array_merge(
649                    $specific_posts,
650                    self::get_specific_posts_from_blocks( $block['innerBlocks'], $block_name )
651                );
652                continue;
653            }
654            if (
655                $block_name === $block['blockName'] &&
656                self::should_deduplicate_block( $block['attrs'] ) &&
657                ! empty( $block['attrs']['specificMode'] ) &&
658                ! empty( $block['attrs']['specificPosts'] )
659            ) {
660                $specific_posts = array_merge(
661                    $specific_posts,
662                    $block['attrs']['specificPosts']
663                );
664            }
665        }
666        return $specific_posts;
667    }
668
669    /**
670     * Builds and returns query args based on block attributes.
671     *
672     * @param array $attributes An array of block attributes.
673     * @param array $block_name Name of the block requesting the query.
674     *
675     * @return array
676     */
677    public static function build_articles_query( $attributes, $block_name ) {
678        global $newspack_blocks_post_id;
679        if ( ! $newspack_blocks_post_id ) {
680            $newspack_blocks_post_id = array();
681        }
682
683        // Get all blocks and gather specificPosts ids of all eligible blocks.
684        global $newspack_blocks_all_specific_posts_ids;
685        if ( ! is_array( $newspack_blocks_all_specific_posts_ids ) ) {
686            $blocks                                 = parse_blocks( get_the_content() );
687            $newspack_blocks_all_specific_posts_ids = self::get_specific_posts_from_blocks( $blocks, $block_name );
688        }
689
690        $post_type              = isset( $attributes['postType'] ) ? $attributes['postType'] : [ 'post' ];
691        $included_post_statuses = [ 'publish' ];
692        if ( current_user_can( 'edit_others_posts' ) && isset( $attributes['includedPostStatuses'] ) ) {
693            $included_post_statuses = $attributes['includedPostStatuses'];
694        }
695        $authors                    = isset( $attributes['authors'] ) ? $attributes['authors'] : array();
696        $categories                 = isset( $attributes['categories'] ) ? $attributes['categories'] : array();
697        $include_subcategories      = isset( $attributes['includeSubcategories'] ) ? intval( $attributes['includeSubcategories'] ) : false;
698        $category_join              = isset( $attributes['categoryJoinType'] ) ? $attributes['categoryJoinType'] : 'or';
699        $tags                       = isset( $attributes['tags'] ) ? $attributes['tags'] : array();
700        $custom_taxonomies          = isset( $attributes['customTaxonomies'] ) ? $attributes['customTaxonomies'] : array();
701        $tag_exclusions             = isset( $attributes['tagExclusions'] ) ? $attributes['tagExclusions'] : array();
702        $category_exclusions        = isset( $attributes['categoryExclusions'] ) ? $attributes['categoryExclusions'] : array();
703        $custom_taxonomy_exclusions = isset( $attributes['customTaxonomyExclusions'] ) ? $attributes['customTaxonomyExclusions'] : array();
704        $specific_posts             = isset( $attributes['specificPosts'] ) ? $attributes['specificPosts'] : array();
705        $posts_to_show              = intval( $attributes['postsToShow'] );
706        $specific_mode              = isset( $attributes['specificMode'] ) ? intval( $attributes['specificMode'] ) : false;
707        $args                       = array(
708            'post_type'           => $post_type,
709            'post_status'         => $included_post_statuses,
710            'suppress_filters'    => false,
711            'ignore_sticky_posts' => true,
712            'has_password'        => false,
713            'is_newspack_query'   => true,
714            'tax_query'           => [], // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
715        );
716        if ( $specific_mode && $specific_posts ) {
717            $args['posts_per_page'] = count( $specific_posts );
718            $args['post__in']       = $specific_posts;
719            $args['orderby']        = 'post__in';
720        } else {
721            $args['posts_per_page'] = $posts_to_show;
722            if ( self::should_deduplicate_block( $attributes ) ) {
723                if ( count( $newspack_blocks_all_specific_posts_ids ) ) {
724                    $args['post__not_in'] = $newspack_blocks_all_specific_posts_ids; // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in
725                }
726                $current_post_id = get_the_ID();
727                $args['post__not_in'] = array_merge( // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in
728                    $args['post__not_in'] ?? [],
729                    array_keys( $newspack_blocks_post_id ),
730                    is_singular() && $current_post_id ? [ $current_post_id ] : []
731                );
732            }
733            if ( $categories && count( $categories ) ) {
734                if ( 'or' === $category_join && 1 === $include_subcategories ) {
735                    $children = [];
736                    foreach ( $categories as $parent ) {
737                        $children = array_merge( $children, get_categories( [ 'child_of' => $parent ] ) );
738                        foreach ( $children as $child ) {
739                            $categories[] = $child->term_id;
740                        }
741                    }
742                }
743                if ( 'or' === $category_join ) {
744                    $args['category__in'] = $categories;
745                } else {
746                    $args['category__and'] = $categories;
747                }
748            }
749            if ( $tags && count( $tags ) ) {
750                $args['tag__in'] = $tags;
751            }
752            if ( $tag_exclusions && count( $tag_exclusions ) ) {
753                $args['tag__not_in'] = $tag_exclusions;
754            }
755            if ( $category_exclusions && count( $category_exclusions ) ) {
756                $args['category__not_in'] = $category_exclusions;
757            }
758            if ( ! empty( $custom_taxonomies ) ) {
759                foreach ( $custom_taxonomies as $taxonomy ) {
760                    if ( ! empty( $taxonomy['slug'] ) && ! empty( $taxonomy['terms'] ) ) {
761                        $args['tax_query'][] = [
762                            'taxonomy'         => $taxonomy['slug'],
763                            'field'            => 'term_id',
764                            'terms'            => $taxonomy['terms'],
765                            'include_children' => false,
766                        ];
767                    }
768                }
769            }
770            if ( $custom_taxonomy_exclusions && count( $custom_taxonomy_exclusions ) ) {
771                foreach ( $custom_taxonomy_exclusions as $exclusion ) {
772                    $args['tax_query'][] = [
773                        'field'            => 'term_id',
774                        'include_children' => false,
775                        'operator'         => 'NOT IN',
776                        'taxonomy'         => $exclusion['slug'],
777                        'terms'            => $exclusion['terms'],
778                    ];
779                }
780            }
781
782            if ( $authors && count( $authors ) ) {
783                global $coauthors_plus;
784                $is_co_authors_plus_active = is_object( $coauthors_plus ) && method_exists( $coauthors_plus, 'get_coauthor_by' );
785
786                if ( ! $is_co_authors_plus_active ) {
787                    $args['author__in'] = $authors;
788                } else {
789                    /**
790                     * When CoAuthors Plus is active, we ignore the 'author__in' parameter and search only by the author taxonomy.
791                     *
792                     * If CAP has been activated recently, the author taxonomy may not have been populated yet. You'll need to run
793                     * wp co-authors-plus create-author-terms-for-posts to make sure all posts have the author terms in place.
794                     */
795                    $authors_term_ids = [];
796                    foreach ( $authors as $author_id ) {
797                        $co_author = $coauthors_plus->get_coauthor_by( 'id', $author_id );
798                        if ( is_object( $co_author ) ) {
799                            $term = $coauthors_plus->get_author_term( $co_author );
800                            if ( $term ) {
801                                $authors_term_ids[] = $term->term_id;
802                            } else {
803                                // If the author term does not exist, force a non-match, otherwise all posts will be returned.
804                                // CAP's cli command to create author terms will only create terms for users that have authored posts.
805                                $authors_term_ids[] = -1;
806                            }
807
808                            // If it's a guest author, also check the linked author.
809                            if ( 'guest-author' === $co_author->type && ! empty( $co_author->wp_user ) && $co_author->wp_user instanceof \WP_User ) {
810                                $term = $coauthors_plus->get_author_term( $co_author->wp_user );
811                                if ( $term ) {
812                                    $authors_term_ids[] = $term->term_id;
813                                }
814                            }
815
816                            // If it's a regular wp user, check and include any linked guest authors.
817                            if ( 'wpuser' === $co_author->type ) {
818                                $authors_controller = new WP_REST_Newspack_Authors_Controller();
819                                $linked_guest_author_post = $authors_controller->get_linked_guest_author( $co_author->user_login );
820                                if ( $linked_guest_author_post ) {
821                                    $linked_guest_author_object = $coauthors_plus->get_coauthor_by( 'id', $author_id );
822                                    if ( is_object( $linked_guest_author_object ) ) {
823                                        $term = $coauthors_plus->get_author_term( $linked_guest_author_object );
824                                        if ( $term ) {
825                                            $authors_term_ids[] = $term->term_id;
826                                        }
827                                    }
828                                }
829                            }
830                        }
831                    }
832                    if ( count( $authors_term_ids ) ) {
833                        $args['tax_query'][] = [
834                            'taxonomy' => 'author',
835                            'field'    => 'term_id',
836                            'terms'    => $authors_term_ids,
837                        ];
838                    }
839                }
840            }
841        }
842
843        /**
844         * Customize the WP_Query arguments to fetch post articles before the actual query is executed.
845         *
846         * The filter is called after the build_articles_query() function is called by a Newspack block to
847         * build the WP_Query arguments based on the given attributes and block requesting the query.
848         *
849         * @param array     $args       WP_Query arguments as created by build_articles_query()
850         * @param array     $attributes The attributes initial passed to build_articles_query()
851         * @param string    $block_name The name of the requesting block to create the query args for
852         */
853        return apply_filters( 'newspack_blocks_build_articles_query', $args, $attributes, $block_name );
854    }
855
856    /**
857     * Loads a template with given data in scope.
858     *
859     * @param string $template full Path to the template to be included.
860     * @param array  $data          Data to be passed into the template to be included.
861     * @return string
862     */
863    public static function template_inc( $template, $data = array() ) { //phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable, Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
864        if ( ! strpos( $template, '.php' ) ) {
865            $template = $template . '.php';
866        }
867        if ( ! is_file( $template ) ) {
868            return '';
869        }
870        ob_start();
871        include $template;
872        $contents = ob_get_contents();
873        ob_end_clean();
874        return $contents;
875    }
876
877    /**
878     * Prepare an array of authors, taking presence of CoAuthors Plus into account.
879     *
880     * @return object[] Array of user objects.
881     */
882    public static function prepare_authors() {
883        $authors = [];
884
885        if ( function_exists( 'get_coauthors' ) ) {
886            $authors = get_coauthors();
887            foreach ( $authors as $author ) {
888                $author->avatar = coauthors_get_avatar( $author, 48 );
889                $author->url    = get_author_posts_url( $author->ID, $author->user_nicename );
890            }
891        }
892
893        if ( empty( $authors ) ) {
894            $id = get_the_author_meta( 'ID' );
895            $authors = array(
896                (object) array(
897                    'ID'            => $id,
898                    'avatar'        => get_avatar( $id, 48 ),
899                    'url'           => get_author_posts_url( $id ),
900                    'user_nicename' => get_the_author(),
901                    'display_name'  => get_the_author_meta( 'display_name' ),
902                ),
903            );
904        }
905
906        /**
907         * Filters the authors array.
908         *
909         * @param object[] $authors Array of user objects.
910         */
911        return apply_filters( 'newspack_blocks_post_authors', $authors );
912    }
913
914    /**
915     * Prepare a list of classes based on assigned tags, categories, post formats and types.
916     *
917     * @param string $post_id Post ID.
918     * @return string CSS classes.
919     */
920    public static function get_term_classes( $post_id ) {
921        $classes = [];
922
923        $tags = get_the_terms( $post_id, 'post_tag' );
924        if ( ! empty( $tags ) ) {
925            foreach ( $tags as $tag ) {
926                if ( ! empty( $tag->slug ) ) {
927                    $classes[] = 'tag-' . $tag->slug;
928                }
929            }
930        }
931
932        $categories = get_the_terms( $post_id, 'category' );
933        if ( ! empty( $categories ) ) {
934            foreach ( $categories as $cat ) {
935                if ( ! empty( $cat->slug ) ) {
936                    $classes[] = 'category-' . $cat->slug;
937                }
938            }
939        }
940
941        foreach ( self::get_custom_taxonomies() as $tax ) {
942            $terms = get_the_terms( $post_id, $tax['slug'] );
943            if ( ! empty( $terms ) ) {
944                foreach ( $terms as $term ) {
945                    if ( ! empty( $term->taxonomy ) && ! empty( $term->slug ) ) {
946                        $classes[] = $term->taxonomy . '-' . $term->slug;
947                    }
948                }
949            }
950        }
951
952        $post_type = get_post_type( $post_id );
953        if ( false !== $post_type ) {
954            $classes[] = 'type-' . $post_type;
955        }
956
957        /**
958         * Filter the array of class names before applying them to the HTML.
959         *
960         * @param array $classes Array of term class names.
961         *
962         * @return array Filtered array of term class names.
963         */
964        $classes = apply_filters( 'newspack_blocks_term_classes', $classes );
965
966        return implode( ' ', $classes );
967    }
968
969    /**
970     * Get patterns for post type.
971     *
972     * @param string $post_type Post type.
973     * @return array Array of patterns.
974     */
975    public static function get_patterns_for_post_type( $post_type = null ) {
976        $patterns    = apply_filters( 'newspack_blocks_patterns', [], $post_type );
977        $categorized = [];
978        $clean       = [];
979        foreach ( $patterns as $pattern ) {
980            if ( ! isset( $pattern['image'] ) || ! $pattern['image'] ) {
981                continue;
982            }
983            $category = isset( $pattern['category'] ) ? $pattern['category'] : __( 'Common', 'jetpack-mu-wpcom' );
984            if ( ! isset( $categorized[ $category ] ) ) {
985                $categorized[ $category ] = [];
986            }
987            $categorized[ $category ][] = $pattern;
988        }
989        $categories = array_keys( $categorized );
990        sort( $categories );
991        foreach ( $categories as $category ) {
992            $clean[] = [
993                'title' => $category,
994                'items' => $categorized[ $category ],
995            ];
996        }
997        return $clean;
998    }
999
1000    /**
1001     * Function to check if plugin is enabled, and if there are sponsors.
1002     *
1003     * @see https://github.com/Automattic/newspack-sponsors/blob/8ebf72ec4fe744bca405a1f6fe8cd5bce3a29e6a/includes/newspack-sponsors-theme-helpers.php#L35
1004     *
1005     * @param int|null    $id    ID of the post or archive term to get sponsors for.
1006     *                           If not provided, we will try to guess based on context.
1007     * @param string|null $scope Scope of the sponsors to get. Can be 'native' or
1008     *                           'underwritten'. If provided, only sponsors with the
1009     *                           matching scope will be returned. If not, all sponsors
1010     *                           will be returned regardless of scope.
1011     * @param string|null $type  Type of the $id given: 'post' or 'archive'. If not
1012     *                           provided, we will try to guess based on context.
1013     * @param array       $logo_options Optional array of logo options. Valid options:
1014     *                                  maxwidth: max width of the logo image, in pixels.
1015     *                                  maxheight: max height of the logo image, in pixels.
1016     * @return array Array of sponsors.
1017     */
1018    public static function get_all_sponsors( $id = null, $scope = 'native', $type = 'post', $logo_options = array(
1019        'maxwidth'  => 80,
1020        'maxheight' => 40,
1021    ) ) {
1022        if ( function_exists( '\Newspack_Sponsors\get_sponsors_for_post' ) ) {
1023            if ( is_singular() ) {
1024                $scope_override = get_post_meta( $id, 'newspack_sponsor_sponsorship_scope', true );
1025
1026                // Scope override: if post is set to display as native-sponsored, return all sponsors.
1027                if ( 'native' === $scope_override ) {
1028                    $scope = null;
1029                }
1030
1031                // Scope override: if post is set to display as underwritten, return nothing.
1032                if ( 'underwritten' === $scope_override ) {
1033                    return [];
1034                }
1035            }
1036
1037            return \Newspack_Sponsors\get_all_sponsors( $id, $scope, $type, $logo_options ); // phpcs:ignore PHPCompatibility.LanguageConstructs.NewLanguageConstructs.t_ns_separatorFound
1038        }
1039
1040        return false;
1041    }
1042
1043    /**
1044     * Function to return sponsor 'flag' from first sponsor.
1045     *
1046     * @param array  $sponsors Array of sponsors.
1047     * @param string $id Post ID.
1048     * @return string|boolean Sponsor flag label, or false if none found.
1049     */
1050    public static function get_sponsor_label( $sponsors = null, $id = null ) {
1051        if ( null === $sponsors && ! empty( $id ) ) {
1052            $sponsors = self::get_all_sponsors( $id );
1053        }
1054
1055        if ( ! empty( $sponsors ) ) {
1056            $sponsor_flag = $sponsors[0]['sponsor_flag'];
1057            return $sponsor_flag;
1058        }
1059
1060        return false;
1061    }
1062
1063    /**
1064     * Outputs the sponsor byline markup for the theme.
1065     *
1066     * @param array  $sponsors Array of sponsors.
1067     * @param string $id Post ID.
1068     * @return array|boolean Array of Sponsor byline information, or false if none found.
1069     */
1070    public static function get_sponsor_byline( $sponsors = null, $id = null ) {
1071        if ( null === $sponsors & ! empty( $id ) ) {
1072            $sponsors = self::get_all_sponsors( $id );
1073        }
1074
1075        if ( ! empty( $sponsors ) ) {
1076            $sponsor_count = count( $sponsors );
1077            $i             = 1;
1078            $sponsor_list  = [];
1079
1080            foreach ( $sponsors as $sponsor ) {
1081                $i++;
1082                if ( $sponsor_count === $i ) :
1083                    /* translators: separates last two sponsor names; needs a space on either side. */
1084                    $sep = esc_html__( ' and ', 'jetpack-mu-wpcom' );
1085                elseif ( $sponsor_count > $i ) :
1086                    /* translators: separates all but the last two sponsor names; needs a space at the end. */
1087                    $sep = esc_html__( ', ', 'jetpack-mu-wpcom' );
1088                else :
1089                    $sep = '';
1090                endif;
1091
1092                $sponsor_list[] = array(
1093                    'byline' => $sponsor['sponsor_byline'],
1094                    'url'    => $sponsor['sponsor_url'],
1095                    'name'   => $sponsor['sponsor_name'],
1096                    'sep'    => $sep,
1097                );
1098            }
1099            return $sponsor_list;
1100        }
1101
1102        return false;
1103    }
1104
1105    /**
1106     * Outputs set of sponsor logos with links.
1107     *
1108     * @param array  $sponsors Array of sponsors.
1109     * @param string $id Post ID.
1110     * @return array Array of sponsor logo images, or false if none found.
1111     */
1112    public static function get_sponsor_logos( $sponsors = null, $id = null ) {
1113        if ( null === $sponsors && ! empty( $id ) ) {
1114            $sponsors = self::get_all_sponsors(
1115                $id,
1116                'native',
1117                'post',
1118                array(
1119                    'maxwidth'  => 80,
1120                    'maxheight' => 40,
1121                )
1122            );
1123        }
1124
1125        if ( ! empty( $sponsors ) ) {
1126            $sponsor_logos = [];
1127            foreach ( $sponsors as $sponsor ) {
1128                if ( ! empty( $sponsor['sponsor_logo'] ) ) :
1129                    $sponsor_logos[] = array(
1130                        'url'    => $sponsor['sponsor_url'],
1131                        'src'    => esc_url( $sponsor['sponsor_logo']['src'] ),
1132                        'alt'    => esc_attr( $sponsor['sponsor_name'] ),
1133                        'width'  => esc_attr( $sponsor['sponsor_logo']['img_width'] ),
1134                        'height' => esc_attr( $sponsor['sponsor_logo']['img_height'] ),
1135                    );
1136                endif;
1137            }
1138
1139            return $sponsor_logos;
1140        }
1141
1142        return false;
1143    }
1144
1145    /**
1146     * If at least one native sponsor is set to display both sponsors and authors, show the authors.
1147     *
1148     * @param array $sponsors Array of sponsors.
1149     *
1150     * @return boolean True if we should display both sponsors and categories, false if we should display only sponsors.
1151     */
1152    public static function newspack_display_sponsors_and_authors( $sponsors ) {
1153        if ( function_exists( '\Newspack_Sponsors\newspack_display_sponsors_and_authors' ) ) {
1154            return \Newspack_Sponsors\newspack_display_sponsors_and_authors( $sponsors );
1155        }
1156        return false;
1157    }
1158
1159    /**
1160     * If at least one native sponsor is set to display both sponsors and categories, show the categories.
1161     *
1162     * @param array $sponsors Array of sponsors.
1163     *
1164     * @return boolean True if we should display both sponsors and categories, false if we should display only sponsors.
1165     */
1166    public static function newspack_display_sponsors_and_categories( $sponsors ) {
1167        if ( function_exists( '\Newspack_Sponsors\newspack_display_sponsors_and_categories' ) ) {
1168            return \Newspack_Sponsors\newspack_display_sponsors_and_categories( $sponsors );
1169        }
1170        return false;
1171    }
1172
1173    /**
1174     * Support for Tag Labels.
1175     *
1176     * @param int|WP_Post|null $post Post to retrieve tag labels for.
1177     *
1178     * @return array|null Tag labels, if any, for this post.
1179     */
1180    public static function get_tag_labels( $post = null ) {
1181        if ( class_exists( '\Newspack\Tag_Labels' ) && method_exists( '\Newspack\Tag_Labels', 'get_labels_for_post' ) ) {
1182            return \Newspack\Tag_Labels::get_labels_for_post( $post );
1183        }
1184
1185        return null;
1186    }
1187
1188    /**
1189     * Outputs HTML for given tag labels.
1190     *
1191     * @param array|null $labels Labels to display.
1192     * @param bool       $links  Whether to include links to tag archives.
1193     *
1194     * @return void
1195     */
1196    public static function display_tag_labels( $labels = null, $links = true ) {
1197        if ( class_exists( '\Newspack\Tag_Labels' ) && method_exists( '\Newspack\Tag_Labels', 'display' ) ) {
1198            \Newspack\Tag_Labels::display( $labels, $links, 'div' );
1199        }
1200    }
1201
1202    /**
1203     * Closure for excerpt filtering that can be added and removed.
1204     *
1205     * @var Closure
1206     */
1207    public static $newspack_blocks_excerpt_closure = null;
1208
1209    /**
1210     * Closure for excerpt length filtering that can be added and removed.
1211     *
1212     * @var Closure
1213     * @deprecated
1214     */
1215    public static $newspack_blocks_excerpt_length_closure = null;
1216
1217    /**
1218     * Function to override WooCommerce Membership's Excerpt Length filter.
1219     *
1220     * @return string Current post's original excerpt.
1221     */
1222    public static function remove_wc_memberships_excerpt_limit() {
1223        $excerpt = get_the_excerpt( get_the_id() );
1224        return $excerpt;
1225    }
1226
1227    /**
1228     * Filter for excerpt length.
1229     *
1230     * @param array $attributes The block's attributes.
1231     */
1232    public static function filter_excerpt( $attributes ) {
1233        if ( empty( $attributes['excerptLength'] ) || ! $attributes['showExcerpt'] ) {
1234            return;
1235        }
1236
1237        self::$newspack_blocks_excerpt_closure = function( $text = '', $post = null ) use ( $attributes ) {
1238            // If we have a manually entered excerpt, use that and allow some tags.
1239            if ( ! empty( $post->post_excerpt ) ) {
1240                $excerpt      = $post->post_excerpt;
1241                $allowed_tags = '<em>,<i>,<strong>,<b>,<u>,<ul>,<ol>,<li>,<h1>,<h2>,<h3>,<h4>,<h5>,<h6>,<img>,<a>,<p>';
1242            } else {
1243                // If we don't, built an excerpt but allow no tags.
1244                $excerpt      = $post->post_content;
1245                $allowed_tags = '';
1246            }
1247
1248            // Recreate logic from wp_trim_excerpt (https://developer.wordpress.org/reference/functions/wp_trim_excerpt/).
1249            $excerpt = strip_shortcodes( $excerpt );
1250            // Strip blocks the content gate withholds from the public before
1251            // excerpt_remove_blocks() flattens the block structure.
1252            if ( class_exists( 'Newspack\Block_Visibility' ) && method_exists( 'Newspack\Block_Visibility', 'strip_blocks_hidden_from_public' ) ) {
1253                $excerpt = \Newspack\Block_Visibility::strip_blocks_hidden_from_public( $excerpt );
1254            }
1255            $excerpt = excerpt_remove_blocks( $excerpt );
1256            $excerpt = wpautop( $excerpt );
1257            $excerpt = str_replace( ']]>', ']]&gt;', $excerpt );
1258
1259            // Strip HTML tags except for the explicitly allowed tags.
1260            $excerpt = strip_tags( $excerpt, $allowed_tags ); // phpcs:ignore WordPressVIPMinimum.Functions.StripTags.StripTagsTwoParameters
1261
1262            // Get excerpt length. If not provided a valid length, use the default excerpt length.
1263            if ( empty( $attributes['excerptLength'] ) || ! is_numeric( $attributes['excerptLength'] ) ) {
1264                $excerpt_length = 55;
1265            } else {
1266                $excerpt_length = $attributes['excerptLength'];
1267            }
1268
1269            // Set excerpt length (https://core.trac.wordpress.org/ticket/29533#comment:3).
1270            $excerpt = force_balance_tags( html_entity_decode( wp_trim_words( htmlentities( $excerpt, ENT_COMPAT ), $excerpt_length, static::more_excerpt(), ENT_COMPAT ) ) );
1271
1272            return $excerpt;
1273        };
1274        add_filter( 'get_the_excerpt', self::$newspack_blocks_excerpt_closure, 11, 2 );
1275    }
1276
1277    /**
1278     * Remove excerpt filter after Homepage Posts block loop.
1279     */
1280    public static function remove_excerpt_filter() {
1281        if ( static::$newspack_blocks_excerpt_closure ) {
1282            remove_filter( 'get_the_excerpt', static::$newspack_blocks_excerpt_closure, 11 );
1283        }
1284    }
1285
1286    /**
1287     * Filter for excerpt length.
1288     *
1289     * @deprecated
1290     * @param array $attributes The block's attributes.
1291     */
1292    public static function filter_excerpt_length( $attributes ) {
1293        // If showing excerpt, filter the length using the block attribute.
1294        if ( isset( $attributes['excerptLength'] ) && $attributes['showExcerpt'] ) {
1295            self::$newspack_blocks_excerpt_length_closure = add_filter(
1296                'excerpt_length',
1297                function() use ( $attributes ) {
1298                    if ( $attributes['excerptLength'] ) {
1299                        return $attributes['excerptLength'];
1300                    }
1301                    return 55;
1302                },
1303                999
1304            );
1305            add_filter( 'wc_memberships_trimmed_restricted_excerpt', [ 'Newspack_Blocks', 'remove_wc_memberships_excerpt_limit' ], 999 );
1306        }
1307    }
1308
1309    /**
1310     * Remove excerpt length filter after Homepage Posts block loop.
1311     *
1312     * @deprecated
1313     */
1314    public static function remove_excerpt_length_filter() {
1315        if ( self::$newspack_blocks_excerpt_length_closure ) {
1316            remove_filter(
1317                'excerpt_length',
1318                self::$newspack_blocks_excerpt_length_closure,
1319                999
1320            );
1321            remove_filter( 'wc_memberships_trimmed_restricted_excerpt', [ 'Newspack_Blocks', 'remove_wc_memberships_excerpt_limit' ] );
1322        }
1323    }
1324
1325    /**
1326     * Return a excerpt more replacement when using the 'Read More' link.
1327     */
1328    public static function more_excerpt() {
1329        return '…';
1330    }
1331
1332    /**
1333     * Filter for excerpt ellipsis.
1334     *
1335     * @deprecated
1336     * @param array $attributes The block's attributes.
1337     */
1338    public static function filter_excerpt_more( $attributes ) {
1339        // If showing the 'Read More' link, modify the ellipsis.
1340        if ( $attributes['showReadMore'] ) {
1341            add_filter( 'excerpt_more', [ __CLASS__, 'more_excerpt' ], 999 );
1342        }
1343    }
1344
1345    /**
1346     * Remove excerpt ellipsis filter after Homepage Posts block loop.
1347     *
1348     * @deprecated
1349     */
1350    public static function remove_excerpt_more_filter() {
1351        remove_filter( 'excerpt_more', [ __CLASS__, 'more_excerpt' ], 999 );
1352    }
1353
1354    /**
1355     * Utility to get the link for the given post ID. If the post has an external URL meta value, use that.
1356     * Otherwise, use the permalink. But if the post type doesn't have a public singular view, don't link.
1357     *
1358     * @param int $post_id Post ID for which to get the link. Will default to current post if none given.
1359     * @return string|boolean The URL for the post, or false if it can't be linked to.
1360     */
1361    public static function get_post_link( $post_id = null ) {
1362        if ( null === $post_id ) {
1363            $post_id = get_the_ID();
1364        }
1365
1366        $post_type        = get_post_type( $post_id );
1367        $sponsor_url      = get_post_meta( $post_id, 'newspack_sponsor_url', true );
1368        $supporter_url    = get_post_meta( $post_id, 'newspack_supporter_url', true );
1369        $external_url     = ! empty( $sponsor_url ) ? $sponsor_url : $supporter_url;
1370        $post_type_info   = get_post_type_object( $post_type );
1371        $link             = ! empty( $external_url ) ? $external_url : get_permalink();
1372        $should_have_link = ! empty( $post_type_info->public ) || ! empty( $external_url ); // False if a sponsor or supporter without an external URL.
1373
1374        return $should_have_link ? $link : false;
1375    }
1376
1377    /**
1378     * Sanitize SVG markup for front-end display.
1379     *
1380     * @param string $svg SVG markup to sanitize.
1381     * @return string Sanitized markup.
1382     */
1383    public static function sanitize_svg( $svg = '' ) {
1384        $allowed_html = [
1385            'svg'  => [
1386                'xmlns'       => [],
1387                'fill'        => [],
1388                'viewbox'     => [],
1389                'role'        => [],
1390                'aria-hidden' => [],
1391                'focusable'   => [],
1392                'height'      => [],
1393                'width'       => [],
1394            ],
1395            'path' => [
1396                'd'    => [],
1397                'fill' => [],
1398            ],
1399        ];
1400
1401        return wp_kses( $svg, $allowed_html );
1402    }
1403
1404    /**
1405     * Disable Jetpack's donate block when using Newspack donations.
1406     */
1407    public static function disable_jetpack_donate() {
1408        // Do nothing if Jetpack's blocks or Newspack aren't being used.
1409        if ( ! class_exists( 'Jetpack_Gutenberg' ) || ! class_exists( 'Newspack' ) ) {
1410            return;
1411        }
1412
1413        // Allow Jetpack donations if Newspack donations isn't set up.
1414        $donate_settings = Newspack\Donations::get_donation_settings();
1415        if ( is_wp_error( $donate_settings ) ) {
1416            return;
1417        }
1418
1419        // Tell Jetpack to mark the donations feature as unavailable.
1420        Jetpack_Gutenberg::set_extension_unavailable(
1421            'jetpack/donations',
1422            esc_html__( 'Jetpack donations is disabled in favour of Newspack donations.', 'jetpack-mu-wpcom' )
1423        );
1424    }
1425
1426    /**
1427     * Loads a template with given data in scope.
1428     *
1429     * @param string $template Name of the template to be included.
1430     * @param array  $data     Data to be passed into the template to be included.
1431     * @param string $path     (Optional) Path to the folder containing the template.
1432     * @return string
1433     */
1434    public static function template_include( $template, $data = [], $path = NEWSPACK_BLOCKS__PLUGIN_DIR . 'src/templates/' ) {
1435        if ( ! strpos( $template, '.php' ) ) {
1436            $template = $template . '.php';
1437        }
1438        $path .= $template;
1439        if ( ! is_file( $path ) ) {
1440            return '';
1441        }
1442        ob_start();
1443        include $path;
1444        $contents = ob_get_contents();
1445        ob_end_clean();
1446        return $contents;
1447    }
1448
1449    /**
1450     * Get post status label.
1451     */
1452    public static function get_post_status_label() {
1453        $post_status          = get_post_status();
1454        $post_statuses_labels = [
1455            'draft'  => __( 'Draft', 'jetpack-mu-wpcom' ),
1456            'future' => __( 'Scheduled', 'jetpack-mu-wpcom' ),
1457        ];
1458        if ( 'publish' !== $post_status ) {
1459            ob_start();
1460            ?>
1461                <div class="newspack-preview-label"><?php echo esc_html( $post_statuses_labels[ $post_status ] ); ?></div>
1462            <?php
1463            return ob_get_clean();
1464        }
1465    }
1466
1467    /**
1468     * Pick either black or white text, whichever reads better on the given background.
1469     *
1470     * Scores pure black and pure white as text against the background and returns
1471     * whichever produces the greater APCA lightness contrast (Lc); ties fall to
1472     * black. The constants are the SA98G set from apca-w3 0.1.9.
1473     *
1474     * Keep in sync with getColorForContrast() in src/blocks/donate/utils.ts.
1475     *
1476     * @param string $hex Hexadecimal background color (#RGB, #RRGGBB or #RRGGBBAA, with or without #).
1477     * @return string Either 'black' or 'white' (literal CSS color keywords).
1478     */
1479    public static function get_color_for_contrast( $hex ) {
1480        $background_y = self::get_apca_luminance( $hex );
1481        $black_lc     = self::get_apca_contrast( $background_y, self::get_apca_luminance( '#000000' ) );
1482        $white_lc     = self::get_apca_contrast( $background_y, self::get_apca_luminance( '#ffffff' ) );
1483
1484        return abs( $white_lc ) > abs( $black_lc ) ? 'white' : 'black';
1485    }
1486
1487    /**
1488     * Compute the soft-clamped APCA screen luminance (Y) of a hex color.
1489     *
1490     * Accepts #RGB, #RRGGBB and #RRGGBBAA (the alpha pair is stripped), with or
1491     * without the leading #, case-insensitively. Unparseable input is treated as
1492     * white (luminance 1.0) so callers fall back to black text.
1493     *
1494     * @param string $hex Hexadecimal color.
1495     * @return float Soft-clamped luminance in the 0..1 range.
1496     */
1497    private static function get_apca_luminance( $hex ) {
1498        $hex = ltrim( trim( (string) $hex ), '#' );
1499        if ( 3 === strlen( $hex ) ) {
1500            $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
1501        } elseif ( 8 === strlen( $hex ) ) {
1502            // Drop the alpha pair from #RRGGBBAA.
1503            $hex = substr( $hex, 0, 6 );
1504        }
1505        if ( 6 !== strlen( $hex ) || ! ctype_xdigit( $hex ) ) {
1506            return 1.0;
1507        }
1508
1509        $r = hexdec( substr( $hex, 0, 2 ) ) / 255;
1510        $g = hexdec( substr( $hex, 2, 2 ) ) / 255;
1511        $b = hexdec( substr( $hex, 4, 2 ) ) / 255;
1512
1513        $y = 0.2126729 * pow( $r, 2.4 ) + 0.7151522 * pow( $g, 2.4 ) + 0.0721750 * pow( $b, 2.4 );
1514
1515        // APCA soft-clamp of near-black luminance.
1516        if ( $y <= 0.022 ) {
1517            $y += pow( 0.022 - $y, 1.414 );
1518        }
1519
1520        return $y;
1521    }
1522
1523    /**
1524     * Compute the APCA lightness contrast (Lc) of text on a background.
1525     *
1526     * Positive values are dark text on a lighter background; negative values are
1527     * light text on a darker background. Both luminances must already be
1528     * soft-clamped.
1529     *
1530     * @param float $background_y Soft-clamped background luminance.
1531     * @param float $text_y       Soft-clamped text luminance.
1532     * @return float The Lc value.
1533     */
1534    private static function get_apca_contrast( $background_y, $text_y ) {
1535        if ( abs( $background_y - $text_y ) < 0.0005 ) {
1536            return 0.0;
1537        }
1538
1539        if ( $background_y > $text_y ) {
1540            $sapc = ( pow( $background_y, 0.56 ) - pow( $text_y, 0.57 ) ) * 1.14;
1541            return $sapc < 0.1 ? 0.0 : ( $sapc - 0.027 ) * 100;
1542        }
1543
1544        $sapc = ( pow( $background_y, 0.65 ) - pow( $text_y, 0.62 ) ) * 1.14;
1545        return $sapc > -0.1 ? 0.0 : ( $sapc + 0.027 ) * 100;
1546    }
1547
1548    /**
1549     * Get an array of allowed HTML attributes for sanitizing image markup.
1550     * For use with wp_kses: https://developer.wordpress.org/reference/functions/wp_kses/
1551     *
1552     * @return array
1553     */
1554    public static function get_sanitized_image_attributes() {
1555        return [
1556            'img'      => [
1557                'alt'      => true,
1558                'class'    => true,
1559                'data-*'   => true,
1560                'decoding' => true,
1561                'height'   => true,
1562                'loading'  => true,
1563                'sizes'    => true,
1564                'src'      => true,
1565                'srcset'   => true,
1566                'width'    => true,
1567            ],
1568            'noscript' => [],
1569            'a'        => [
1570                'href' => true,
1571            ],
1572        ];
1573    }
1574
1575    /**
1576     * Get post date to be displayed.
1577     *
1578     * @param WP_Post $post Post object.
1579     * @return string Date string.
1580     */
1581    public static function get_displayed_post_date( $post = null ) {
1582        if ( $post === null ) {
1583            $post = get_post();
1584        }
1585        return apply_filters( 'newspack_blocks_displayed_post_date', mysql_to_rfc3339( $post->post_date ), $post );
1586    }
1587
1588    /**
1589     * Get post date in ISO-8601 format to be used in the datetime attribute.
1590     *
1591     * @param WP_Post $post Post object.
1592     * @return string Date string in ISO-8601 format.
1593     */
1594    public static function get_datetime_post_date( $post = null ) {
1595        if ( $post === null ) {
1596            $post = get_post();
1597        }
1598        /**
1599         * Filters the post date used for the datetime attribute.
1600         *
1601         * @param string Date string in a format appropriate for datetime attributes.
1602         */
1603        return apply_filters( 'newspack_blocks_displayed_post_date', get_post_datetime( $post )->format( 'c' ), $post );
1604    }
1605
1606    /**
1607     * Get post date to be displayed, formatted.
1608     *
1609     * @param WP_Post $post Post object.
1610     * @return string Formatted date.
1611     */
1612    public static function get_formatted_displayed_post_date( $post = null ) {
1613        if ( $post === null ) {
1614            $post = get_post();
1615        }
1616        $date           = self::get_displayed_post_date( $post );
1617        $date           = new DateTime( $date );
1618        $date_format    = get_option( 'date_format' );
1619        $date_formatted = date_i18n( $date_format, $date->getTimestamp() );
1620        return apply_filters( 'newspack_blocks_formatted_displayed_post_date', $date_formatted, $post );
1621    }
1622
1623    /**
1624     * Get article meta footer.
1625     *
1626     * @param WP_Post $post Post object.
1627     */
1628    public static function get_article_meta_footer( $post = null ) {
1629        if ( $post === null ) {
1630            $post = get_post();
1631        }
1632        $meta_footer = apply_filters( 'newspack_blocks_article_meta_footer', '', $post );
1633        if ( strlen( $meta_footer ) > 0 ) {
1634            return '<span style="margin: 0 6px;" class="newspack_blocks__article-meta-footer__separator">|</span>' . $meta_footer;
1635        }
1636    }
1637
1638    /**
1639     * Get a formatted HTML string containing amount and frequency of a donation.
1640     *
1641     * @param float  $amount Amount.
1642     * @param string $frequency Frequency.
1643     * @param bool   $hide_once_label Whether to hide the "once" label.
1644     *
1645     * @return string
1646     */
1647    public static function get_formatted_amount( $amount = null, $frequency = null, $hide_once_label = false ) {
1648        if ( ! function_exists( 'wc_price' ) || ( method_exists( 'Newspack\Donations', 'is_platform_wc' ) && ! \Newspack\Donations::is_platform_wc() ) ) {
1649            if ( empty( $amount ) ) {
1650                return false;
1651            }
1652
1653            // Translators: %s is the %s is the frequency.
1654            $frequency_string = 'once' === $frequency ? $frequency : sprintf( __( 'per %s', 'jetpack-mu-wpcom' ), $frequency );
1655            $formatter        = new NumberFormatter( \get_locale(), NumberFormatter::CURRENCY );
1656            $formatted_price  = '<span class="price-amount">' . $formatter->formatCurrency( $amount, 'USD' ) . '</span> <span class="tier-frequency">' . $frequency_string . '</span>';
1657            return str_replace( '.00', '', $formatted_price );
1658        }
1659
1660        $wc_formatted_amount = '';
1661        if ( null === $amount && null === $frequency ) {
1662            $currency_symbol     = function_exists( 'get_woocommerce_currency_symbol' ) ? \get_woocommerce_currency_symbol() : '&#36;';
1663            $wc_formatted_amount = '<span class="woocommerce-Price-amount amount"><bdi><span class="woocommerce-Price-currencySymbol">' . $currency_symbol . '</span>AMOUNT_PLACEHOLDER</bdi></span> FREQUENCY_PLACEHOLDER';
1664        } else {
1665            // If it's a float but with no decimal value, treat it as an int.
1666            if ( is_float( $amount ) && floor( $amount ) == $amount ) {
1667                $amount = (int) $amount;
1668            }
1669            // Format the amount with currency symbol and separators.
1670            $amount_string = \wc_price(
1671                $amount,
1672                [ 'decimals' => is_int( $amount ) ? 0 : 2 ]
1673            );
1674
1675            if ( ! function_exists( 'wcs_price_string' ) ) {
1676                return $amount_string;
1677            }
1678            $price_args          = [
1679                'recurring_amount'    => $amount_string,
1680                'subscription_period' => 'once' === $frequency ? 'day' : $frequency,
1681            ];
1682            $wc_formatted_amount = \wcs_price_string( $price_args );
1683
1684            if ( 'once' === $frequency ) {
1685                $once_label          = $hide_once_label ? '' : __( ' once', 'jetpack-mu-wpcom' );
1686                $wc_formatted_amount = preg_replace( '/ \/ ?.*/', $once_label, $wc_formatted_amount );
1687            }
1688            $wc_formatted_amount = str_replace( ' / ', __( ' per ', 'jetpack-mu-wpcom' ), $wc_formatted_amount );
1689        }
1690
1691        return '<span class="wpbnbd__tiers__amount__value">' . $wc_formatted_amount . '</span>';
1692    }
1693
1694    /**
1695     * Get an image caption, optionally with credit appended.
1696     *
1697     * @param int  $attachment_id Attachment ID of the image.
1698     * @param bool $include_caption Whether to include the caption.
1699     * @param bool $include_credit Whether to include the credit.
1700     *
1701     * @return string
1702     */
1703    public static function get_image_caption( $attachment_id = null, $include_caption = true, $include_credit = false ) {
1704        if ( ! $attachment_id || ( ! $include_caption && ! $include_credit ) ) {
1705            return '';
1706        }
1707
1708        $caption = $include_caption ? wp_get_attachment_caption( $attachment_id ) : '';
1709        $credit  = '';
1710
1711        if ( $include_credit && method_exists( 'Newspack\Newspack_Image_Credits', 'get_media_credit_string' ) ) {
1712            $credit = \Newspack\Newspack_Image_Credits::get_media_credit_string( $attachment_id );
1713        }
1714
1715        $full_caption = trim( $caption . ' ' . $credit );
1716        if ( empty( $full_caption ) ) {
1717            return '';
1718        }
1719
1720        $combined_caption = sprintf(
1721            '<figcaption%1$s>%2$s</figcaption>',
1722            ! empty( $credit ) ? ' class="has-credit"' : '',
1723            $full_caption
1724        );
1725
1726        return $combined_caption;
1727    }
1728}
1729Newspack_Blocks::init();