array( orderby, order ).
*/
const SORTS = array(
'date-desc' => array( 'date', 'DESC' ),
'date-asc' => array( 'date', 'ASC' ),
'modified-desc' => array( 'modified', 'DESC' ),
'title-asc' => array( 'title', 'ASC' ),
'title-desc' => array( 'title', 'DESC' ),
'comment_count-desc' => array( 'comment_count', 'DESC' ),
);
/**
* Evita que nuestro propio filtro se aplique al calcular conteos.
*
* @var bool
*/
private static $computing_counts = false;
/**
* Variables originales de la consulta principal, antes de filtrarla.
*
* @var array|null
*/
private static $main_vars = null;
/**
* Parámetros originales de la petición de la consulta principal (para conteos).
*
* @var array|null
*/
private static $main_request = null;
/**
* Caché del escaneo de bloques de la página actual.
*
* @var array|null
*/
private static $scan = null;
public static function init() {
add_action( 'init', array( __CLASS__, 'register_blocks' ) );
add_filter( 'block_categories_all', array( __CLASS__, 'block_category' ) );
add_filter( 'query_loop_block_query_vars', array( __CLASS__, 'filter_query_block_vars' ), 10, 2 );
add_filter( 'render_block_data', array( __CLASS__, 'enable_client_navigation' ), 5 );
add_filter( 'render_block_core/query', array( __CLASS__, 'add_router_region' ), 20, 2 );
add_filter( 'render_block_context', array( __CLASS__, 'enable_client_navigation_context' ) );
add_filter( 'posts_orderby', array( __CLASS__, 'title_orderby' ), 10, 2 );
}
/**
* Registra los bloques. Las traducciones (PHP y scripts del editor) las carga
* WordPress desde wp-content/languages/plugins (translate.wordpress.org).
*/
public static function register_blocks() {
foreach ( self::BLOCKS as $block ) {
register_block_type( FILTERWELL_DIR . '/build/' . $block );
}
}
/**
* Categoría "FilterWell" en el insertador, primera de la lista, para que
* los bloques del plugin se encuentren juntos.
*
* @param array $categories Categorías de bloques.
*/
public static function block_category( $categories ) {
array_unshift(
$categories,
array(
'slug' => 'filterwell',
'title' => 'FilterWell',
'icon' => null,
)
);
return $categories;
}
/**
* Prefijo de parámetros para un bucle.
*/
public static function prefix( $query_id, $inherit ) {
return $inherit ? 'qlf' : 'qlf' . absint( $query_id );
}
/**
* Texto de un atributo o, si está vacío, el texto por defecto traducido.
*
* Los textos por defecto no se guardan en block.json (ahí no se pueden
* traducir): el atributo queda vacío y se usa el de la traducción del sitio.
*
* @param array $attributes Atributos del bloque.
* @param string $name Atributo.
* @param string $fallback Texto por defecto, ya traducido.
*/
public static function text( array $attributes, $name, $fallback ) {
$value = $attributes[ $name ] ?? '';
return is_string( $value ) && '' !== trim( $value ) ? $value : $fallback;
}
/**
* Convierte un color guardado (`var:preset|color|slug` o valor directo) a CSS.
*/
public static function color_value( $value ) {
if ( ! is_string( $value ) || '' === $value ) {
return '';
}
if ( preg_match( '/^var:preset\|color\|([a-z0-9-]+)$/', $value, $match ) ) {
return 'var(--wp--preset--color--' . $match[1] . ')';
}
return preg_match( '/^(#[0-9a-f]{3,8}|(rgb|hsl)a?\([0-9.,%\s\/]+\))$/i', $value ) ? $value : '';
}
/**
* Variables CSS de los colores de las casillas (fondo, borde, marca).
*/
public static function option_color_style( array $attributes ) {
return self::color_vars_style(
$attributes,
array(
'optionElement' => '--qlf-option-bg',
'optionElementBorder' => '--qlf-option-border',
'optionElementSelected' => '--qlf-option-selected',
'optionElementSelectedText' => '--qlf-option-selected-text',
)
);
}
/**
* Convierte atributos de color en variables CSS.
*
* @param array $attributes Atributos del bloque.
* @param array $map atributo => variable CSS.
*/
public static function color_vars_style( array $attributes, array $map ) {
$style = '';
foreach ( $map as $attribute => $variable ) {
$color = self::color_value( $attributes[ $attribute ] ?? '' );
if ( $color ) {
$style .= $variable . ':' . $color . ';';
}
}
return $style;
}
/**
* Clases y estilos de borde, color y tipografía del bloque para aplicarlos a un
* input (equivale a lo que hace el bloque Buscar del núcleo con su campo).
*
* @param array $attributes Atributos del bloque.
* @return array{class: string, style: string}
*/
public static function input_style_attributes( array $attributes ) {
$styles = array_intersect_key( $attributes['style'] ?? array(), array_flip( array( 'border', 'color', 'typography' ) ) );
$engine = wp_style_engine_get_styles( $styles );
$classes = isset( $engine['classnames'] ) ? explode( ' ', $engine['classnames'] ) : array();
$presets = array(
'borderColor' => array( 'has-border-color', 'has-%s-border-color' ),
'textColor' => array( 'has-text-color', 'has-%s-color' ),
'backgroundColor' => array( 'has-background', 'has-%s-background-color' ),
'fontSize' => array( '', 'has-%s-font-size' ),
'fontFamily' => array( '', 'has-%s-font-family' ),
);
foreach ( $presets as $attribute => $pattern ) {
if ( ! empty( $attributes[ $attribute ] ) ) {
$classes[] = $pattern[0];
$classes[] = sprintf( $pattern[1], sanitize_html_class( $attributes[ $attribute ] ) );
}
}
if ( ! empty( $styles['color']['text'] ) ) {
$classes[] = 'has-text-color';
}
if ( ! empty( $styles['color']['background'] ) ) {
$classes[] = 'has-background';
}
return array(
'class' => implode( ' ', array_unique( array_filter( $classes ) ) ),
'style' => $engine['css'] ?? '',
);
}
/**
* Imprime el atributo data-qlf-url (enlace al archivo de un término), si hay URL.
*
* @param string $url URL sin escapar.
*/
public static function url_attr( $url ) {
if ( $url ) {
echo ' data-qlf-url="' . esc_url( $url ) . '"';
}
}
/**
* Título con el mismo markup que el bloque Título del núcleo, para que use
* la tipografía de encabezados definida en los estilos globales.
*
* @param int $level Nivel (2-6).
* @param string $html Contenido HTML ya escapado.
* @param array $extra Atributos extra.
*/
public static function heading( $level, $html, array $extra = array() ) {
$level = min( 6, max( 2, (int) $level ) );
$attrs = '';
foreach ( $extra as $name => $value ) {
$attrs .= sprintf( ' %s="%s"', esc_attr( $name ), esc_attr( $value ) );
}
return sprintf( '%3$s', $level, $attrs, $html );
}
/**
* ID estable para una región del router.
*
* Debe ser igual en la página actual y en la que se navega, así que no se usa
* `wp_unique_id()` (su contador global cambia si otros bloques lo usan, por
* ejemplo el botón "Limpiar filtros", que solo aparece con filtros activos).
*
* @param string $base Base del ID.
*/
public static function region_id( $base ) {
static $counters = array();
$counters[ $base ] = ( $counters[ $base ] ?? 0 ) + 1;
return $base . '-' . $counters[ $base ];
}
/**
* Nombre del parámetro de URL de una taxonomía.
*/
public static function tax_param( $prefix, $taxonomy, $logic ) {
if ( self::AUTHOR === $taxonomy ) {
return $prefix . '_au';
}
return $prefix . ( 'and' === $logic ? '_ta_' : '_t_' ) . $taxonomy;
}
/**
* Término del archivo actual (categoría, etiqueta, taxonomía o autor), si lo es.
*
* Un autor se representa como un término de la "taxonomía" AUTHOR: slug =
* nicename, term_id = ID del usuario.
*
* @return WP_Term|object|null
*/
public static function archive_term() {
$queried = get_queried_object();
if ( is_author() && $queried instanceof WP_User ) {
return self::author_as_term( $queried );
}
if ( ! is_category() && ! is_tag() && ! is_tax() ) {
return null;
}
return $queried instanceof WP_Term ? $queried : null;
}
/**
* Un usuario con la forma de un término (para el filtro por autor).
*
* @param WP_User $user Usuario.
*/
public static function author_as_term( WP_User $user ) {
return (object) array(
'term_id' => $user->ID,
'slug' => $user->user_nicename,
'name' => $user->display_name,
'parent' => 0,
'taxonomy' => self::AUTHOR,
);
}
/**
* URL de archivo de un término (o de un autor).
*
* @param WP_Term|object $term Término.
* @return string URL o cadena vacía.
*/
public static function term_url( $term ) {
if ( self::AUTHOR === $term->taxonomy ) {
return get_author_posts_url( $term->term_id, $term->slug );
}
$url = get_term_link( $term );
return is_wp_error( $url ) ? '' : $url;
}
/**
* ¿El bucle de este prefijo sigue el término del archivo?
*/
public static function follows_archive( $prefix ) {
if ( 'qlf' === $prefix ) {
return true; // Hereda la consulta de la plantilla: ya está filtrado por el archivo.
}
return in_array( (int) substr( $prefix, 3 ), self::scan()['follow'], true );
}
/**
* Término del archivo si una taxonomía de un bucle debe partir filtrada por él.
*
* @param string $taxonomy Taxonomía del filtro.
* @param string $prefix Prefijo del bucle.
* @return WP_Term|null
*/
public static function archive_term_for( $taxonomy, $prefix ) {
$term = self::archive_term();
return $term && $term->taxonomy === $taxonomy && self::follows_archive( $prefix ) ? $term : null;
}
/**
* Período del archivo por fecha actual (/2025/, /2025/09/ o /2025/09/14/).
*
* @return array{from: string, to: string}|null Fechas AAAA-MM-DD, inclusive.
*/
public static function archive_date_range() {
if ( ! is_date() ) {
return null;
}
$year = (int) get_query_var( 'year' );
$month = (int) get_query_var( 'monthnum' );
$day = (int) get_query_var( 'day' );
if ( ! $year ) {
return null;
}
if ( $day && $month ) {
$from = sprintf( '%04d-%02d-%02d', $year, $month, $day );
return array(
'from' => $from,
'to' => $from,
);
}
if ( $month ) {
return array(
'from' => sprintf( '%04d-%02d-01', $year, $month ),
'to' => sprintf( '%04d-%02d-%02d', $year, $month, (int) gmdate( 't', gmmktime( 0, 0, 0, $month, 1, $year ) ) ),
);
}
return array(
'from' => sprintf( '%04d-01-01', $year ),
'to' => sprintf( '%04d-12-31', $year ),
);
}
/**
* URL de "Todas": el archivo del tipo de contenido (la página de entradas, para posts).
*/
public static function all_items_url( $post_type ) {
$url = get_post_type_archive_link( $post_type );
return $url ? $url : home_url( '/' );
}
/**
* Clave de SORTS para un orderby / order de WP_Query.
*/
public static function sort_key( $orderby, $order ) {
$orderby = is_string( $orderby ) && '' !== $orderby ? preg_replace( '/^post_/', '', $orderby ) : 'date';
$order = is_string( $order ) && 'ASC' === strtoupper( $order ) ? 'asc' : 'desc';
return $orderby . '-' . $order;
}
/**
* Orden propio de un bucle (sin el parámetro de orden de la URL).
*
* @param int $query_id ID del bucle.
* @param bool $inherit Si hereda la consulta de la plantilla.
*/
public static function default_sort( $query_id, $inherit ) {
if ( $inherit ) {
$vars = self::main_query_vars();
return self::sort_key( $vars['orderby'] ?? '', $vars['order'] ?? '' );
}
$query = self::scan()['queries'][ (int) $query_id ]['query'] ?? array();
return self::sort_key( $query['orderBy'] ?? 'date', $query['order'] ?? 'desc' );
}
/**
* Lee de la URL los filtros activos para un prefijo.
*
* @param string $prefix Prefijo de parámetros.
* @param bool $with_archive Incluir el término del archivo actual como filtro.
* @return array{s: string, tax: array, from: string, to: string, date_archive: bool, order: string}
*/
public static function get_request( $prefix, $with_archive = true ) {
$request = array(
's' => '',
'tax' => array(),
'from' => '',
'to' => '',
'date_archive' => false, // El rango viene del archivo por fecha, no de la URL.
'order' => '',
);
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- Filtros públicos de solo lectura.
foreach ( $_GET as $key => $value ) {
$key = (string) $key;
if ( $prefix . '_s' === $key && is_string( $value ) ) {
$request['s'] = sanitize_text_field( wp_unslash( $value ) );
continue;
}
// Fechas: solo AAAA-MM-DD válidas.
foreach ( array( 'from', 'to' ) as $edge ) {
if ( $prefix . '_' . $edge === $key && is_string( $value ) && preg_match( '/^(\d{4})-(\d{2})-(\d{2})$/', $value, $date )
&& checkdate( (int) $date[2], (int) $date[3], (int) $date[1] ) ) {
$request[ $edge ] = $value;
}
}
if ( $prefix . '_o' === $key && is_string( $value ) && isset( self::SORTS[ $value ] ) ) {
$request['order'] = $value;
continue;
}
if ( $prefix . '_au' === $key ) {
$values = is_array( $value ) ? array_filter( $value, 'is_string' ) : explode( ',', (string) $value );
$authors = array_values( array_filter( array_map( 'sanitize_title', wp_unslash( $values ) ) ) );
if ( $authors ) {
$request['tax'][ self::AUTHOR ] = array(
'terms' => $authors,
'logic' => 'or',
'param' => $key,
);
}
continue;
}
foreach ( array( 'or' => '_t_', 'and' => '_ta_' ) as $logic => $infix ) {
$start = $prefix . $infix;
if ( 0 !== strpos( $key, $start ) ) {
continue;
}
$taxonomy = substr( $key, strlen( $start ) );
if ( ! taxonomy_exists( $taxonomy ) || ! is_taxonomy_viewable( $taxonomy ) ) {
continue;
}
$values = is_array( $value ) ? array_filter( $value, 'is_string' ) : explode( ',', (string) $value );
$terms = array_values( array_filter( array_map( 'sanitize_title', wp_unslash( $values ) ) ) );
if ( $terms ) {
$request['tax'][ $taxonomy ] = array(
'terms' => $terms,
'logic' => $logic,
'param' => $key,
);
}
}
}
// phpcs:enable
$archive = $with_archive ? self::archive_term() : null;
if ( $archive && ! isset( $request['tax'][ $archive->taxonomy ] ) && self::follows_archive( $prefix ) ) {
$request['tax'][ $archive->taxonomy ] = array(
'terms' => array( $archive->slug ),
'logic' => 'or',
'param' => self::tax_param( $prefix, $archive->taxonomy, 'or' ),
'archive' => true,
);
}
// En un archivo por fecha (p. ej. /2025/09/), su período es el rango de fechas.
$range = $with_archive && ! $request['from'] && ! $request['to'] ? self::archive_date_range() : null;
if ( $range && self::follows_archive( $prefix ) ) {
$request['from'] = $range['from'];
$request['to'] = $range['to'];
$request['date_archive'] = true;
}
return $request;
}
/**
* Aplica los filtros de la URL a un array de argumentos de WP_Query.
*
* @param array $args Argumentos de la consulta.
* @param string $prefix Prefijo de parámetros.
* @param string|null $except_tax Taxonomía a omitir (para conteos).
* @param bool $with_archive Incluir el término del archivo actual.
*/
public static function apply_to_args( array $args, $prefix, $except_tax = null, $with_archive = true ) {
$request = self::get_request( $prefix, $with_archive );
if ( '' !== $request['s'] ) {
$args['s'] = $request['s'];
}
if ( $request['order'] ) {
list( $args['orderby'], $args['order'] ) = self::SORTS[ $request['order'] ];
// Ordenar por título sin los signos iniciales (ver title_orderby()).
$args['filterwell_title'] = 'title' === $args['orderby'];
}
if ( $request['from'] || $request['to'] ) {
$range = array( 'inclusive' => true );
if ( $request['from'] ) {
$range['after'] = $request['from'];
}
if ( $request['to'] ) {
$range['before'] = $request['to'];
}
$args['date_query'] = empty( $args['date_query'] ) ? array( $range ) : array( 'relation' => 'AND', $args['date_query'], $range );
}
$clauses = array();
foreach ( $request['tax'] as $taxonomy => $filter ) {
if ( $taxonomy === $except_tax ) {
continue;
}
if ( self::AUTHOR === $taxonomy ) {
$ids = array();
foreach ( $filter['terms'] as $nicename ) {
$user = get_user_by( 'slug', $nicename );
if ( $user ) {
$ids[] = $user->ID;
}
}
$args['author__in'] = $ids ? $ids : array( 0 ); // Autor inexistente: sin resultados.
continue;
}
$clauses[] = array(
'taxonomy' => $taxonomy,
'field' => 'slug',
'terms' => $filter['terms'],
'operator' => 'and' === $filter['logic'] ? 'AND' : 'IN',
'include_children' => true,
);
}
if ( $clauses ) {
$tax_query = array( 'relation' => 'AND' );
if ( ! empty( $args['tax_query'] ) ) {
$tax_query[] = $args['tax_query'];
}
$args['tax_query'] = array_merge( $tax_query, $clauses ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
}
return $args;
}
/**
* Orden por título que ignora los signos iniciales: "¿Cómo…?" va con la C
* y no antes de la A. Los acentos y mayúsculas ya los ignora el cotejamiento.
*
* Se quitan con TRIM anidados (y no REGEXP_REPLACE) para funcionar en
* cualquier versión de MySQL y MariaDB que soporta WordPress.
*
* @param string $orderby Cláusula ORDER BY.
* @param WP_Query $query Consulta.
*/
public static function title_orderby( $orderby, $query ) {
if ( ! $query->get( 'filterwell_title' ) ) {
return $orderby;
}
global $wpdb;
$title = "{$wpdb->posts}.post_title";
foreach ( array( ' ', '"', "'", '“', '”', '‘', '’', '«', '»', '(', '[', '¿', '¡' ) as $char ) {
$title = "TRIM(LEADING '" . esc_sql( $char ) . "' FROM $title)";
}
$order = 'ASC' === strtoupper( (string) $query->get( 'order' ) ) ? 'ASC' : 'DESC';
return "$title $order, {$wpdb->posts}.ID $order";
}
/**
* Bucles con consulta propia.
*/
public static function filter_query_block_vars( $query, $block ) {
if ( self::$computing_counts || ! isset( $block->context['queryId'] ) ) {
return $query;
}
$query = self::apply_to_args( $query, self::prefix( $block->context['queryId'], false ) );
// El orden propio del bucle por título usa el mismo criterio que el selector.
if ( 'title' === ( $query['orderby'] ?? '' ) && in_array( (int) $block->context['queryId'], self::scan()['targets'], true ) ) {
$query['filterwell_title'] = true;
}
return $query;
}
/**
* Variables de la consulta principal tal como llegó (sin los filtros).
*/
public static function main_query_vars() {
if ( null !== self::$main_vars ) {
return self::$main_vars;
}
return isset( $GLOBALS['wp_query'] ) ? (array) $GLOBALS['wp_query']->query_vars : array();
}
/**
* Parámetros de la petición de la consulta principal (sin los filtros).
*/
public static function main_query_request() {
if ( null !== self::$main_request ) {
return self::$main_request;
}
return isset( $GLOBALS['wp_query'] ) ? (array) $GLOBALS['wp_query']->query : array();
}
/**
* ¿Hay algún filtro en la URL para este prefijo?
*
* @param array $request Resultado de get_request().
*/
public static function has_filters( array $request ) {
return '' !== $request['s'] || $request['tax'] || $request['from'] || $request['to'] || $request['order'];
}
/**
* Bucles que heredan la consulta de la plantilla: justo antes de dibujar un
* bucle así que tenga filtros conectados, la consulta principal se vuelve a
* hacer con los filtros de la URL.
*
* Se hace aquí y no en `pre_get_posts` porque ahí aún no se conoce la
* plantilla: modificar siempre la consulta principal haría que los
* parámetros afectaran a cualquier página, aunque no tenga filtros.
*
* @param array $parsed_block Bloque core/query.
*/
private static function maybe_filter_main_query( array $parsed_block ) {
if ( null !== self::$main_vars || empty( $parsed_block['attrs']['query']['inherit'] ) ) {
return;
}
// La consulta principal ya está filtrada por el archivo: solo se aplican los parámetros de la URL.
if ( ! self::has_filters( self::get_request( 'qlf', false ) ) ) {
return;
}
global $wp_query;
self::$main_vars = (array) $wp_query->query_vars;
self::$main_request = (array) $wp_query->query;
$wp_query = new WP_Query( self::apply_to_args( self::$main_vars, 'qlf', null, false ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Los bloques del bucle heredado leen la consulta global.
}
/**
* Marca como "paginación mejorada" los bucles que tienen filtros apuntándolos.
*
* Esto permite que el núcleo detecte bloques incompatibles con la navegación
* sin recarga dentro del bucle (y, en ese caso, la desactive).
*/
public static function enable_client_navigation( $parsed_block ) {
if ( 'core/query' !== $parsed_block['blockName'] || ! isset( $parsed_block['attrs']['queryId'] ) ) {
return $parsed_block;
}
$scan = self::scan();
if ( in_array( (int) $parsed_block['attrs']['queryId'], $scan['targets'], true ) ) {
$parsed_block['attrs']['enhancedPagination'] = true;
self::maybe_filter_main_query( $parsed_block );
}
return $parsed_block;
}
/**
* Pasa `enhancedPagination` a los bloques internos del bucle (paginación),
* para que sus enlaces también naveguen sin recargar.
*
* @param array $context Contexto del bloque.
*/
public static function enable_client_navigation_context( $context ) {
if ( isset( $context['queryId'] ) && empty( $context['enhancedPagination'] )
&& in_array( (int) $context['queryId'], self::scan()['targets'], true ) ) {
$context['enhancedPagination'] = true;
}
return $context;
}
/**
* Agrega al bucle las directivas de la región navegable.
*
* En bloques anidados el núcleo ya leyó los atributos antes de que
* `render_block_data` los cambie, así que las directivas se agregan aquí
* igual que lo hace `render_block_core_query()`.
*
* @param string $content Contenido renderizado.
* @param array $block Bloque parseado.
*/
public static function add_router_region( $content, $block ) {
if ( ! isset( $block['attrs']['queryId'] ) || false !== strpos( $content, 'data-wp-router-region' ) ) {
return $content;
}
$query_id = (int) $block['attrs']['queryId'];
if ( ! in_array( $query_id, self::scan()['targets'], true ) ) {
return $content;
}
$processor = new WP_HTML_Tag_Processor( $content );
if ( ! $processor->next_tag() ) {
return $content;
}
$processor->set_attribute( 'data-wp-interactive', 'core/query' );
$processor->set_attribute( 'data-wp-router-region', 'query-' . $query_id );
$processor->set_attribute( 'data-wp-context', '{}' );
$processor->set_attribute( 'data-wp-key', (string) $query_id );
wp_enqueue_script_module( '@wordpress/block-library/query/view' );
return $processor->get_updated_html();
}
/**
* Recorre los bloques de la plantilla y del contenido actual para encontrar
* bucles de consulta y bloques de filtros.
*
* @return array{queries: array, targets: int[]}
*/
public static function scan() {
if ( null !== self::$scan ) {
return self::$scan;
}
self::$scan = array(
'queries' => array(),
'targets' => array(),
'follow' => array(),
);
$sources = array();
if ( ! empty( $GLOBALS['_wp_current_template_content'] ) ) {
$sources[] = $GLOBALS['_wp_current_template_content'];
}
if ( is_singular() ) {
$post = get_queried_object();
if ( $post instanceof WP_Post ) {
$sources[] = $post->post_content;
}
}
foreach ( $sources as $source ) {
self::walk( parse_blocks( $source ), 0 );
}
self::$scan['targets'] = array_values( array_unique( self::$scan['targets'] ) );
return self::$scan;
}
private static function walk( array $blocks, $depth ) {
if ( $depth > 10 ) {
return;
}
foreach ( $blocks as $block ) {
$name = $block['blockName'];
$attrs = $block['attrs'];
if ( 'core/query' === $name && isset( $attrs['queryId'] ) ) {
self::$scan['queries'][ (int) $attrs['queryId'] ] = $attrs;
} elseif ( 'filterwell/filters' === $name && isset( $attrs['queryId'] ) ) {
self::$scan['targets'][] = (int) $attrs['queryId'];
if ( $attrs['followArchive'] ?? true ) {
self::$scan['follow'][] = (int) $attrs['queryId'];
}
} elseif ( 'core/block' === $name && ! empty( $attrs['ref'] ) ) {
$reusable = get_post( $attrs['ref'] );
if ( $reusable && 'wp_block' === $reusable->post_type ) {
self::walk( parse_blocks( $reusable->post_content ), $depth + 1 );
}
} elseif ( 'core/pattern' === $name && ! empty( $attrs['slug'] ) ) {
// Patrones del tema insertados en plantillas ().
$pattern = WP_Block_Patterns_Registry::get_instance()->get_registered( $attrs['slug'] );
if ( ! empty( $pattern['content'] ) ) {
self::walk( parse_blocks( $pattern['content'] ), $depth + 1 );
}
} elseif ( 'core/template-part' === $name && ! empty( $attrs['slug'] ) ) {
$theme = $attrs['theme'] ?? get_stylesheet();
$part = get_block_template( $theme . '//' . $attrs['slug'], 'wp_template_part' );
if ( $part && ! empty( $part->content ) ) {
self::walk( parse_blocks( $part->content ), $depth + 1 );
}
}
if ( ! empty( $block['innerBlocks'] ) ) {
self::walk( $block['innerBlocks'], $depth + 1 );
}
}
}
/**
* Cuenta cuántos resultados tendría cada término de una taxonomía (o cada autor)
* considerando los demás filtros activos (como hace WooCommerce).
*
* @param string $taxonomy Taxonomía.
* @param array $context Contexto del bloque de filtros (queryId, inherit, postType).
* @return array term_id => cantidad.
*/
public static function term_counts( $taxonomy, array $context ) {
$query_id = (int) ( $context['filterwell/queryId'] ?? 0 );
$inherit = ! empty( $context['filterwell/inherit'] );
$prefix = self::prefix( $query_id, $inherit );
if ( $inherit ) {
$base = self::main_query_request();
unset( $base['paged'] );
// En el archivo de esta misma taxonomía, los demás términos se cuentan
// sin la restricción del término del archivo.
$archive = self::archive_term();
if ( $archive && $archive->taxonomy === $taxonomy ) {
$query_var = self::AUTHOR === $taxonomy ? '' : get_taxonomy( $taxonomy )->query_var;
unset( $base['cat'], $base['category_name'], $base['tag'], $base['tag_id'], $base['taxonomy'], $base['term'], $base['author'], $base['author_name'] );
if ( $query_var ) {
unset( $base[ $query_var ] );
}
$base['post_type'] = $base['post_type'] ?? ( $context['filterwell/postType'] ?? 'post' );
}
} else {
$scan = self::scan();
if ( isset( $scan['queries'][ $query_id ] ) ) {
$query_block = new WP_Block(
array(
'blockName' => 'core/post-template',
'attrs' => array(),
'innerBlocks' => array(),
),
array(
'query' => $scan['queries'][ $query_id ]['query'] ?? array(),
'queryId' => $query_id,
)
);
self::$computing_counts = true;
$base = build_query_vars_from_query_block( $query_block, 1 );
self::$computing_counts = false;
} else {
$base = array( 'post_type' => $context['filterwell/postType'] ?? 'post' );
}
}
$args = array_merge(
self::apply_to_args( $base, $prefix, $taxonomy ),
array(
'fields' => 'ids',
'posts_per_page' => -1,
'offset' => 0,
'no_found_rows' => true,
'ignore_sticky_posts' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
)
);
unset( $args['paged'] );
// Caché: la clave cambia sola cuando se modifican entradas o términos.
$key = 'filterwell_counts_' . md5(
wp_json_encode( array( $taxonomy, $args ) )
. wp_cache_get_last_changed( 'posts' )
. wp_cache_get_last_changed( 'terms' )
);
$counts = wp_cache_get( $key, 'filterwell' );
if ( false === $counts ) {
$counts = get_transient( $key );
}
if ( false === $counts ) {
$counts = self::count_objects( $taxonomy, ( new WP_Query( $args ) )->posts );
wp_cache_set( $key, $counts, 'filterwell', HOUR_IN_SECONDS );
// Con texto de búsqueda las combinaciones son infinitas: no se guardan en la base de datos.
if ( empty( $args['s'] ) ) {
set_transient( $key, $counts, HOUR_IN_SECONDS );
}
}
return $counts;
}
/**
* Cuenta, entre los contenidos dados, cuántos tiene cada término (o autor).
*
* @param string $taxonomy Taxonomía (o AUTHOR).
* @param int[] $ids IDs de los contenidos.
* @return array term_id (o ID del autor) => cantidad.
*/
private static function count_objects( $taxonomy, array $ids ) {
if ( ! $ids ) {
return array();
}
global $wpdb;
// Autores: cantidad de contenidos por autor.
if ( self::AUTHOR === $taxonomy ) {
$counts = array();
foreach ( array_chunk( array_map( 'intval', $ids ), 1000 ) as $chunk ) {
$placeholders = implode( ',', array_fill( 0, count( $chunk ), '%d' ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Resultado guardado en caché por term_counts().
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT post_author, COUNT(*) AS total FROM {$wpdb->posts} WHERE ID IN ({$placeholders}) GROUP BY post_author", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Marcadores %d generados arriba.
$chunk
)
);
foreach ( $rows as $row ) {
$counts[ (int) $row->post_author ] = ( $counts[ (int) $row->post_author ] ?? 0 ) + (int) $row->total;
}
}
return $counts;
}
$objects_by_term = array();
foreach ( array_chunk( array_map( 'intval', $ids ), 1000 ) as $chunk ) {
$placeholders = implode( ',', array_fill( 0, count( $chunk ), '%d' ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Resultado guardado en caché por term_counts().
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT tt.term_id, tr.object_id FROM {$wpdb->term_relationships} tr
INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
WHERE tt.taxonomy = %s AND tr.object_id IN ({$placeholders})", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Marcadores %d generados arriba.
array_merge( array( $taxonomy ), $chunk )
)
);
foreach ( $rows as $row ) {
$objects_by_term[ (int) $row->term_id ][ (int) $row->object_id ] = true;
// En taxonomías jerárquicas, el padre también cuenta los posts de sus hijos.
if ( is_taxonomy_hierarchical( $taxonomy ) ) {
foreach ( get_ancestors( (int) $row->term_id, $taxonomy, 'taxonomy' ) as $ancestor ) {
$objects_by_term[ $ancestor ][ (int) $row->object_id ] = true;
}
}
}
}
return array_map( 'count', $objects_by_term );
}
}