Todos los artículos

Desarrollo WordPress

Búsqueda instantánea al escribir en un glosario de WordPress

Por Rolando Escobar, desarrollador WordPress para agencias · · 5 min de lectura

Cuando alguien necesita una definición en medio de una llamada, una búsqueda que recarga la página es demasiado lenta. Una búsqueda en vivo que satura el servidor, muestra resultados desordenados o mueve el foco del teclado sin permiso es peor.

El NaturalSlim Resource Center, una base de conocimiento privada para un equipo de call center, tiene diccionarios que los agentes consultan mientras escriben, con resultados por AJAX. Este es un tutorial para crear ese tipo de búsqueda en WordPress: un endpoint REST con argumentos validados, una petición con debounce que cancela las llamadas viejas, una consulta que se mantiene rápida y resultados que funcionan con teclado y lector de pantalla. No es el código del proyecto.

Endpoint REST o admin-ajax

Ambos funcionan. Para una búsqueda de solo lectura, una ruta REST encaja mejor:

  • Los argumentos se declaran, se validan y se sanitizan antes de que corra tu callback.
  • Es una petición GET, así que en un glosario público el navegador o una CDN pueden guardar los resultados en caché.
  • El control de acceso vive en permission_callback, que WordPress exige en cada ruta.
  • admin-ajax.php carga la parte administrativa de WordPress y dispara admin_init, donde algunos plugins hacen trabajo pesado.

admin-ajax sigue teniendo sentido cuando extiendes código que ya lo usa. Para un patrón relacionado, filtrar un custom post type por palabra clave y taxonomía, mira mi plugin de código abierto CPT-Ajax-Filter-Search.

Registra una ruta con argumentos estrictos

Aquí el glosario es un custom post type llamado rw_term. La ruta recibe un solo argumento, q, y el script recibe la URL y un nonce de REST:

add_action( 'rest_api_init', function (): void {
	register_rest_route(
		'rw-glossary/v1',
		'/search',
		array(
			'methods'             => WP_REST_Server::READABLE,
			'callback'            => 'rw_glossary_search',
			'permission_callback' => fn() => current_user_can( 'read' ), // Usa '__return_true' para un glosario público.
			'args'                => array(
				'q' => array(
					'type'              => 'string',
					'required'          => true,
					'minLength'         => 2,
					'maxLength'         => 60,
					'validate_callback' => 'rest_validate_request_arg',
					'sanitize_callback' => 'sanitize_text_field',
				),
			),
		)
	);
} );

add_action( 'wp_enqueue_scripts', function (): void {
	wp_enqueue_script( 'rw-glossary', plugins_url( 'glossary.js', __FILE__ ), array(), '1.0.0', array( 'in_footer' => true ) );
	wp_localize_script( 'rw-glossary', 'rwGlossary', array(
		'endpoint' => rest_url( 'rw-glossary/v1/search' ),
		'nonce'    => wp_create_nonce( 'wp_rest' ),
		'i18n'     => array(
			'none'  => __( 'No terms found.', 'rw-glossary' ),
			'one'   => __( '1 term found.', 'rw-glossary' ),
			'many'  => __( '%d terms found.', 'rw-glossary' ),
			'error' => __( 'Search is unavailable. Try again in a moment.', 'rw-glossary' ),
		),
	) );
} );

Hay dos detalles que suelen causar problemas. Primero, la línea de validate_callback: cuando defines tu propio sanitize_callback, WordPress deja de aplicar por ti las reglas del esquema (minLength, maxLength), así que tienes que volver a apuntar la validación a rest_validate_request_arg. Segundo, el nonce: con autenticación por cookies, una petición REST sin el encabezado X-WP-Nonce se trata como si no hubiera sesión, aunque el usuario la tenga iniciada. En un sitio privado, eso significa un 401 en cada búsqueda. La documentación de autenticación de la REST API explica por qué.

Una prueba rápida antes de escribir JavaScript: pide la ruta con q=m y deberías recibir una respuesta 400 con el código rest_invalid_param y el mensaje «q must be at least 2 characters long.» Si esa petición devuelve resultados, la validación no se está ejecutando.

Una consulta que se mantiene rápida

En un glosario, lo que la gente espera es que coincida el título, y eso es mucho más barato que la búsqueda por defecto de WordPress, que revisa títulos, extractos y contenido. La consulta de abajo encuentra los títulos que contienen el texto y pone primero los que empiezan con él:

function rw_glossary_search( WP_REST_Request $request ): WP_REST_Response {
	global $wpdb;

	$q         = $request['q'];
	$cache_key = md5( $q ) . ':' . wp_cache_get_last_changed( 'posts' );
	$results   = wp_cache_get( $cache_key, 'rw_glossary' );

	if ( false === $results ) {
		$like = $wpdb->esc_like( $q );
		$ids  = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT ID FROM {$wpdb->posts}
				WHERE post_type = 'rw_term' AND post_status = 'publish' AND post_title LIKE %s
				ORDER BY ( post_title LIKE %s ) DESC, post_title ASC
				LIMIT 10",
				'%' . $like . '%',
				$like . '%'
			)
		);

		$results = array();
		if ( $ids ) { // Un post__in vacío devolvería todos los términos.
			$posts = get_posts( array( 'post_type' => 'rw_term', 'post__in' => $ids, 'orderby' => 'post__in', 'posts_per_page' => 10 ) );
			foreach ( $posts as $post ) {
				$results[] = array(
					'title'   => rw_glossary_plain( get_the_title( $post ) ),
					'excerpt' => rw_glossary_plain( wp_trim_words( get_the_excerpt( $post ), 20 ) ),
					'link'    => get_permalink( $post ),
				);
			}
		}
		wp_cache_set( $cache_key, $results, 'rw_glossary', 10 * MINUTE_IN_SECONDS );
	}

	return rest_ensure_response( $results );
}

// El navegador los inserta con textContent: envía texto plano, no entidades HTML.
function rw_glossary_plain( string $html ): string {
	return html_entity_decode( wp_strip_all_tags( $html ), ENT_QUOTES, 'UTF-8' );
}

Notas sobre la consulta:

  • $wpdb->esc_like() va antes de prepare(). Sin eso, un % o un _ escrito por el usuario funcionaría como comodín.
  • La base de datos se encarga de los acentos. Con las collations que WordPress suele usar, como utf8mb4_unicode_520_ci, LIKE ignora mayúsculas y acentos, así que «metabolico» encuentra «metabólico».
  • La segunda consulta precarga la caché. get_posts() carga las diez entradas de una vez, así que los enlaces permanentes y los extractos no cuestan una consulta cada uno.
  • post_title no tiene índice. Unos pocos miles de términos se recorren en milisegundos. Para decenas de miles, mueve los términos a una tabla propia con una columna indexada. Una búsqueda por prefijo (LIKE 'abc%') puede usar ese índice; una búsqueda de «contiene» no, así que a ese tamaño considera un índice FULLTEXT.

Si prefieres quedarte con WP_Query, su argumento search_columns (WordPress 6.2 o posterior) limita la búsqueda a post_title. Pero pierdes el orden que prioriza el prefijo, y WordPress divide la frase en palabras separadas que deben coincidir todas.

La clave de caché incluye wp_cache_get_last_changed( 'posts' ), que cambia cada vez que se guarda una entrada, así que editar un término invalida los resultados viejos sin código de limpieza. Sin una caché de objetos persistente, wp_cache_* solo dura lo que dura la petición, y está bien: la consulta es barata. No la cambies por transients. Una fila por cada texto buscado llenaría wp_options de basura.

Marcado accesible

Empieza con un formulario de búsqueda real que funcione sin JavaScript y agrega los resultados en vivo encima:

<form role="search" action="/" method="get" data-glossary>
  <input type="hidden" name="post_type" value="rw_term">
  <label for="rw-glossary-q">Search the glossary</label>
  <input id="rw-glossary-q" name="s" type="search" autocomplete="off"
         aria-describedby="rw-glossary-hint" aria-controls="rw-glossary-results">
  <p id="rw-glossary-hint">Results update as you type. Enter at least 2 characters.</p>

  <p id="rw-glossary-status" role="status" aria-live="polite" aria-atomic="true"></p>
  <ul id="rw-glossary-results" aria-label="Glossary results"></ul>
</form>

La etiqueta visible, la pista vinculada con aria-describedby y la línea de estado cortés hacen la mayor parte del trabajo. Los resultados son una lista simple de enlaces, no un combobox ARIA. Un combobox con selección por flechas es un compromiso mayor; si lo necesitas, sigue el patrón combobox de las APG completo y no solo la mitad.

Debounce, cancelación y render seguro

const form = document.querySelector('[data-glossary]');

if (form) {
  const input = form.querySelector('input[type="search"]');
  const list = form.querySelector('#rw-glossary-results');
  const status = form.querySelector('#rw-glossary-status');
  const { endpoint, nonce, i18n } = window.rwGlossary;
  let controller;
  let timer;

  const render = (items) => {
    list.replaceChildren(...items.map((item) => {
      const li = document.createElement('li');
      const link = document.createElement('a');
      link.href = item.link;
      link.textContent = item.title;
      li.append(link);
      if (item.excerpt) li.append(document.createTextNode(` ${item.excerpt}`));
      return li;
    }));
    const n = items.length;
    status.textContent = n === 0 ? i18n.none : n === 1 ? i18n.one : i18n.many.replace('%d', n);
  };

  const search = async () => {
    const q = input.value.trim();
    controller?.abort(); // Cancela la petición de la tecla anterior.
    if (q.length < 2) {
      list.replaceChildren();
      status.textContent = '';
      return;
    }
    controller = new AbortController();
    const url = new URL(endpoint);
    url.searchParams.set('q', q);
    try {
      const response = await fetch(url, { headers: { 'X-WP-Nonce': nonce }, signal: controller.signal });
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      render(await response.json());
    } catch (error) {
      if (error.name !== 'AbortError') status.textContent = i18n.error;
    }
  };

  input.addEventListener('input', () => {
    clearTimeout(timer);
    timer = setTimeout(search, 250);
  });
  form.addEventListener('submit', (event) => {
    event.preventDefault();
    clearTimeout(timer);
    search();
  });
  input.addEventListener('keydown', (event) => {
    if (event.key === 'ArrowDown' && list.querySelector('a')) {
      event.preventDefault();
      list.querySelector('a').focus();
    }
  });
}

Lo que evita cada pieza:

  • El debounce de 250 ms envía una sola petición cuando el usuario hace una pausa, no una por tecla.
  • AbortController cancela la petición anterior, así que una respuesta lenta para «met» no puede sobrescribir los resultados de «metab».
  • textContent y createElement hacen que el título de un término nunca pueda inyectar HTML, aunque un editor pegue algo raro.
  • El foco nunca se mueve solo. Los resultados aparecen mientras el cursor sigue en el campo. Solo la flecha hacia abajo, cuando el usuario la presiona, mueve el foco al primer resultado, y Tab funciona como siempre.
  • La línea de estado anuncia la cantidad, una vez y con cortesía. Quien usa lector de pantalla sabe que hay resultados sin que le lean la lista completa.

Qué vigilar

  • Haz visible el foco en los enlaces de resultados con un contorno en :focus-visible. Los estilos por defecto del navegador muchas veces desaparecen sobre fondos personalizados.
  • Si los anuncios de la cantidad resultan insistentes para quienes escriben rápido, actualiza la línea de estado con un retraso un poco mayor que el de la búsqueda.
  • Mantén la longitud mínima en dos caracteres o más. Las búsquedas de una letra devuelven ruido y son las que más cuestan.
  • Presionar Enter ejecuta la búsqueda de inmediato. Sin JavaScript, el mismo formulario usa la búsqueda normal de WordPress para el post type rw_term.
  • En un glosario público, envía encabezados Cache-Control en la respuesta REST para que una CDN absorba las consultas repetidas. En uno privado, no lo hagas.

Este es el tipo de trabajo que hago para clientes: Plugins e integraciones