$args Normalized args. * @return string */ public static function grid( array $args ) { $instance = ++self::$instances; $page_var = self::page_var( $instance ); $page = self::current_page( $page_var ); $query = Query_Builder::query( $args, $page ); if ( ! $query->have_posts() ) { return self::empty_state( $args ); } $max_pages = Query_Builder::max_pages( $query, $args ); $dom_id = '' !== $args['id'] ? $args['id'] : 'mmpg-' . $instance; $classes = self::grid_classes( $args ); $style = self::grid_style( $args ); $context = array( 'instance' => $instance, 'page' => $page, 'page_var' => $page_var, 'max_pages' => $max_pages, 'dom_id' => $dom_id, ); ob_start(); /** * Fires before a grid is printed. * * @since 0.1 * * @param array $args Normalized args. * @param \WP_Query $query Grid query. */ do_action( 'mmpg_before_grid', $args, $query ); ?>
>
$args Normalized args. * @param \WP_Query $query Grid query. */ do_action( 'mmpg_after_grid', $args, $query ); $html = (string) ob_get_clean(); /** * Filters the complete grid markup. * * @since 0.1 * * @param string $html Grid markup. * @param array $args Normalized args. * @param \WP_Query $query Grid query. */ return apply_filters( 'mmpg_grid_html', $html, $args, $query ); } /** * Returns the HTML the grid is allowed to contain, for wp_kses(). * * The grid passes through several public filters (mmpg_grid_html, * mmpg_card_html and the card part filters), so its final markup is always * escaped with wp_kses() before it leaves the plugin: in the shortcode * callback and in the load more REST response. * * The list is exactly the markup the grid produces, not the generic post * allowlist. wp_kses_post() would strip srcset, sizes, decoding and * fetchpriority from every image and silently turn off responsive images. * Inline formatting that post titles may contain is kept as well. * * @since 0.1 * @return array> */ public static function allowed_html() { $common = array( 'class' => true, 'id' => true, 'aria-hidden' => true, 'aria-label' => true, 'data-*' => true, ); $inline = array( 'class' => true ); $allowed = array( // Structure. Per grid custom properties live in the section style. 'section' => $common + array( 'style' => true ), 'div' => $common, 'article' => $common, 'nav' => $common, 'ul' => $common, 'li' => $common, 'p' => $common + array( 'role' => true ), 'span' => $common + array( 'aria-current' => true ), 'a' => $common + array( 'href' => true, 'rel' => true, 'tabindex' => true, 'target' => true, 'aria-current' => true, ), 'time' => array( 'class' => true, 'datetime' => true, ), // Every attribute wp_get_attachment_image() can print. 'img' => array( 'src' => true, 'srcset' => true, 'sizes' => true, 'alt' => true, 'width' => true, 'height' => true, 'class' => true, 'loading' => true, 'decoding' => true, 'fetchpriority' => true, ), // Inline formatting a post title may contain. 'strong' => $inline, 'em' => $inline, 'b' => $inline, 'i' => $inline, 'br' => array(), 'sub' => $inline, 'sup' => $inline, 'mark' => $inline, 'code' => $inline, 'abbr' => $inline + array( 'title' => true ), ); // The title tag is a setting, so every level it accepts is allowed. foreach ( array( 'h2', 'h3', 'h4', 'h5', 'h6' ) as $heading ) { $allowed[ $heading ] = $common; } /** * Filters the HTML allowed in the grid output. * * Extend this when a markup filter adds tags or attributes of its own; * anything not listed here is removed before the grid reaches the page. * * @since 0.1 * * @param array> $allowed Allowed tags and attributes, as for wp_kses(). */ return (array) apply_filters( 'mmpg_allowed_html', $allowed ); } /** * Renders the cards of an executed query. * * Used by the shortcode and by the load more endpoint. * * @since 0.1 * * @param \WP_Query $query Executed query. * @param array $args Normalized args. * @return string */ public static function cards( \WP_Query $query, array $args ) { ob_start(); while ( $query->have_posts() ) { $query->the_post(); echo self::card( get_post(), $args ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped in Renderer::card(). } wp_reset_postdata(); return (string) ob_get_clean(); } /** * Renders one card. * * @since 0.1 * * @param \WP_Post|null $post Post object. * @param array $args Normalized args. * @return string */ public static function card( $post, array $args ) { if ( ! $post instanceof \WP_Post ) { return ''; } $permalink = (string) get_permalink( $post ); $title = self::title( $post, $args ); $media = ! empty( $args['show_image'] ) ? self::media( $post, $args, $permalink, $title ) : ''; $classes = array( 'mmpg-card' ); if ( '' === $media ) { $classes[] = 'mmpg-card--no-media'; } /** * Filters the CSS classes of a card. * * @since 0.1 * * @param string[] $classes Class names. * @param \WP_Post $post Post object. * @param array $args Normalized args. */ $classes = apply_filters( 'mmpg_card_classes', $classes, $post, $args ); ob_start(); /** * Fires before a card is printed. * * @since 0.1 * * @param \WP_Post $post Post object. * @param array $args Normalized args. */ do_action( 'mmpg_before_card', $post, $args ); ?>
< class="mmpg-title"> >
$args Normalized args. */ do_action( 'mmpg_after_card', $post, $args ); $html = (string) ob_get_clean(); /** * Filters the markup of a single card. * * @since 0.1 * * @param string $html Card markup. * @param \WP_Post $post Post object. * @param array $args Normalized args. */ return apply_filters( 'mmpg_card_html', $html, $post, $args ); } /** * Returns the page query variable for a grid instance. * * The first grid on a page uses `mmpg-page`, further grids get a suffix so * that two paginated grids do not move together. * * @since 0.1 * * @param int $instance Grid instance number. * @return string */ public static function page_var( $instance ) { return $instance > 1 ? 'mmpg-page-' . (int) $instance : 'mmpg-page'; } /** * Reads the requested page from the query string. * * @since 0.1 * * @param string $page_var Query variable name. * @return int */ public static function current_page( $page_var ) { // A plain (int) cast rather than absint(): absint() would turn // ?mmpg-page=-9 into page 9 instead of falling back to the first page. // The cast is applied to the superglobal directly, which both sanitizes // it and makes unslashing moot, since no slash survives an int cast. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public pagination state, no action is performed. $page = isset( $_GET[ $page_var ] ) ? (int) $_GET[ $page_var ] : 1; return max( 1, $page ); } /** * Returns the post title, with a fallback for untitled posts. * * @since 0.1 * * @param \WP_Post $post Post object. * @param array $args Normalized args. * @return string */ private static function title( \WP_Post $post, array $args ) { $title = get_the_title( $post ); if ( '' === trim( wp_strip_all_tags( $title ) ) ) { $title = __( '(no title)', 'mm-post-grid' ); } /** * Filters the card title. * * @since 0.1 * * @param string $title Post title. * @param \WP_Post $post Post object. * @param array $args Normalized args. */ return (string) apply_filters( 'mmpg_card_title', $title, $post, $args ); } /** * Returns the media block of a card. * * The image link is hidden from assistive technology when the title link is * present, so the same destination is not announced twice per card. * * @since 0.1 * * @param \WP_Post $post Post object. * @param array $args Normalized args. * @param string $permalink Post permalink. * @param string $title Post title. * @return string */ private static function media( \WP_Post $post, array $args, $permalink, $title ) { $attachment_id = (int) get_post_thumbnail_id( $post ); $html = ''; if ( $attachment_id > 0 ) { $image = wp_get_attachment_image( $attachment_id, $args['image_size'], false, array( 'class' => 'mmpg-image' ) ); if ( '' !== $image ) { $has_title = ! empty( $args['show_title'] ); ob_start(); ?> '; } /** * Filters the media block of a card. * * @since 0.1 * * @param string $html Media markup, empty when there is none. * @param \WP_Post $post Post object. * @param array $args Normalized args. */ return (string) apply_filters( 'mmpg_card_media', $html, $post, $args ); } /** * Returns the taxonomy line of a card. * * @since 0.1 * * @param \WP_Post $post Post object. * @param array $args Normalized args. * @return string */ private static function terms( \WP_Post $post, array $args ) { if ( empty( $args['show_category'] ) ) { return ''; } $taxonomy = self::primary_taxonomy( $post, $args ); if ( '' === $taxonomy ) { return ''; } $terms = get_the_terms( $post, $taxonomy ); if ( is_wp_error( $terms ) || empty( $terms ) ) { return ''; } /** * Filters how many terms a card shows. * * @since 0.1 * * @param int $limit Maximum number of terms. * @param \WP_Post $post Post object. * @param array $args Normalized args. */ $limit = (int) apply_filters( 'mmpg_card_terms_limit', 1, $post, $args ); $terms = array_slice( $terms, 0, max( 1, $limit ) ); ob_start(); ?>

name ); ?> name ); ?>

$args Normalized args. * @return string */ private static function primary_taxonomy( \WP_Post $post, array $args ) { $taxonomy = ''; if ( is_object_in_taxonomy( $post->post_type, 'category' ) ) { $taxonomy = 'category'; } else { $taxonomies = get_object_taxonomies( $post->post_type, 'objects' ); foreach ( $taxonomies as $object ) { if ( ! empty( $object->public ) && ! empty( $object->show_ui ) ) { $taxonomy = $object->name; break; } } } /** * Filters the taxonomy shown on a card. * * @since 0.1 * * @param string $taxonomy Taxonomy name, empty to hide the line. * @param \WP_Post $post Post object. * @param array $args Normalized args. */ return (string) apply_filters( 'mmpg_card_taxonomy', $taxonomy, $post, $args ); } /** * Returns the date and author line of a card. * * @since 0.1 * * @param \WP_Post $post Post object. * @param array $args Normalized args. * @return string */ private static function meta( \WP_Post $post, array $args ) { $show_date = ! empty( $args['show_date'] ); $show_author = ! empty( $args['show_author'] ); if ( ! $show_date && ! $show_author ) { return ''; } ob_start(); ?>

post_author ) ) ); ?>

$args Normalized args. */ return (string) apply_filters( 'mmpg_card_meta', $html, $post, $args ); } /** * Returns the excerpt block of a card. * * @since 0.1 * * @param \WP_Post $post Post object. * @param array $args Normalized args. * @return string */ private static function excerpt( \WP_Post $post, array $args ) { if ( empty( $args['show_excerpt'] ) ) { return ''; } $length = (int) $args['excerpt_length']; $excerpt = get_the_excerpt( $post ); if ( $length > 0 ) { $excerpt = wp_trim_words( $excerpt, $length, '…' ); } /** * Filters the card excerpt. * * @since 0.1 * * @param string $excerpt Excerpt text. * @param \WP_Post $post Post object. * @param array $args Normalized args. */ $excerpt = (string) apply_filters( 'mmpg_card_excerpt', $excerpt, $post, $args ); if ( '' === trim( wp_strip_all_tags( $excerpt ) ) ) { return ''; } return '
' . wp_kses_post( $excerpt ) . '
'; } /** * Returns the read more link of a card. * * The hidden suffix gives every link a unique accessible name. * * @since 0.1 * * @param \WP_Post $post Post object. * @param array $args Normalized args. * @param string $permalink Post permalink. * @param string $title Post title. * @return string */ private static function read_more( \WP_Post $post, array $args, $permalink, $title ) { if ( empty( $args['show_read_more'] ) ) { return ''; } /** * Filters the read more label. * * @since 0.1 * * @param string $text Link label. * @param \WP_Post $post Post object. * @param array $args Normalized args. */ $text = (string) apply_filters( 'mmpg_read_more_text', (string) $args['read_more_text'], $post, $args ); if ( '' === trim( $text ) ) { $text = __( 'Read more', 'mm-post-grid' ); } $context = sprintf( /* translators: %s: Post title. */ _x( 'about %s', 'read more link', 'mm-post-grid' ), $title ); ob_start(); ?> $args Normalized args. * @param array $context Instance, page, page_var, max_pages, dom_id. * @return string */ private static function pagination( array $args, array $context ) { if ( 'none' === $args['pagination'] || $context['max_pages'] < 2 ) { return ''; } if ( 'load_more' === $args['pagination'] ) { return self::load_more( $args, $context ); } $page = (int) $context['page']; $max_pages = (int) $context['max_pages']; $numbers = 'numbers' === $args['pagination']; $label = 1 === (int) $context['instance'] ? __( 'Post grid pagination', 'mm-post-grid' ) /* translators: %d: Grid number on the page. */ : sprintf( __( 'Post grid %d pagination', 'mm-post-grid' ), (int) $context['instance'] ); ob_start(); ?> $args Normalized args. * @param array $context Instance context. * @return string */ private static function load_more( array $args, array $context ) { Assets::enqueue_load_more(); $next = (int) $context['page'] + 1; $payload = wp_json_encode( Args::to_payload( $args ) ); ob_start(); ?>

$context Instance context. * @param int $page Target page. * @return string */ private static function page_url( array $context, $page ) { $page = (int) $page; $base = remove_query_arg( (string) $context['page_var'], add_query_arg( array() ) ); $url = $page > 1 ? add_query_arg( (string) $context['page_var'], $page, $base ) : $base; return $url . '#' . (string) $context['dom_id']; } /** * Returns the wrapper classes. * * @since 0.1 * * @param array $args Normalized args. * @return string */ private static function grid_classes( array $args ) { $classes = array( 'mmpg', 'mmpg--cols-' . (int) $args['columns'], ); // Every modifier maps a sanitized choice to one predictable class, so // a theme can target any of them without reading plugin settings. $modifiers = array( 'card_layout' => 'layout', 'hover_effect' => 'hover', 'text_align' => 'align', 'image_hover' => 'image', 'term_style' => 'terms', 'button_style' => 'more', 'button_size' => 'button', 'pagination_align' => 'pagination', ); foreach ( $modifiers as $key => $prefix ) { if ( isset( $args[ $key ] ) && '' !== (string) $args[ $key ] ) { $classes[] = 'mmpg--' . $prefix . '-' . sanitize_html_class( (string) $args[ $key ] ); } } if ( 'auto' === $args['image_ratio'] ) { $classes[] = 'mmpg--ratio-auto'; } if ( ! empty( $args['title_lines'] ) ) { $classes[] = 'mmpg--clamp-title'; } if ( ! empty( $args['excerpt_lines'] ) ) { $classes[] = 'mmpg--clamp-excerpt'; } if ( '' !== $args['class'] ) { $classes = array_merge( $classes, explode( ' ', $args['class'] ) ); } /** * Filters the wrapper classes. * * @since 0.1 * * @param string[] $classes Class names. * @param array $args Normalized args. */ $classes = apply_filters( 'mmpg_grid_classes', $classes, $args ); return self::class_list( $classes ); } /** * Builds the per instance custom properties. * * Only values that differ from the saved settings are printed, so most * grids carry no inline style at all. * * @since 0.1 * * @param array $args Normalized args. * @return string */ private static function grid_style( array $args ) { // Only per grid values that differ from the saved settings end up in // the style attribute; everything else comes from the stylesheet or // the site wide inline CSS. $keys = array( 'columns', 'columns_tablet', 'columns_mobile', 'gap', 'image_ratio', 'title_lines', 'excerpt_lines' ); $declarations = array(); foreach ( $keys as $key ) { if ( ! array_key_exists( $key, $args ) || (string) Settings::get( $key ) === (string) $args[ $key ] ) { continue; } $declarations = array_merge( $declarations, Assets::declarations( $key, $args[ $key ] ) ); } return implode( ';', $declarations ); } /** * Sanitizes and joins a list of class names. * * @since 0.1 * * @param mixed $classes Class names. * @return string */ private static function class_list( $classes ) { $classes = is_array( $classes ) ? $classes : array( $classes ); $classes = array_filter( array_map( 'sanitize_html_class', $classes ) ); return implode( ' ', array_unique( $classes ) ); } /** * Returns the empty state markup. * * @since 0.1 * * @param array $args Normalized args. * @return string */ private static function empty_state( array $args ) { /** * Filters the message shown when a grid has no posts. * * Return an empty string to output nothing at all. * * @since 0.1 * * @param string $message Message text. * @param array $args Normalized args. */ $message = (string) apply_filters( 'mmpg_empty_message', __( 'No posts found.', 'mm-post-grid' ), $args ); if ( '' === trim( $message ) ) { return ''; } return '

' . esc_html( $message ) . '

'; } }