Cómo eliminar el aviso de zona horaria de Modern Events Calendar Lite

Valencia, 17/09/2026, G.B.
Aunque lo he publicado en los foros de Puntocomunica, quiero dejar también aquí constancia, para que no se me olvide si volviera a necesitar solucionarlo. Creo en la redundancia en la Red…

Sitio: (no lo indico por privacidad)
Plugin implicado: Modern Events Calendar Lite (Webnus), v7.36.4
Fecha de resolución: septiembre de 2026

El problema

Tras importar eventos en el calendario, aparece este aviso en el escritorio
de WordPress, en cualquier pantalla del área de administración y no solo en
los ajustes del calendario:

It is advisable to utilize a geographic timezone, such as
«America/Los_Angeles» instead of a UTC timezone offset, like «UTC+0,»
while using The Modern Events Calendar. The latter may cause issues when
importing events or with Daylight Saving Time.

Causa raíz

El propio plugin comprueba, en
wp-content/plugins/modern-events-calendar-lite/app/features/events.php
—líneas 208-218—, si la opción interna de WordPress
timezone_string está vacía:

// Timezone Notice
$timezone_string = get_option('timezone_string');

if (trim($timezone_string) === '')
{
    add_action('admin_notices', function ()
    {
        echo '<div class="notice notice-warning is-dismissible">
            <p>' . esc_html__('It is advisable to utilize a geographic timezone...', 'modern-events-calendar-lite') . '</p>
        </div>';
    });
}

timezone_string queda vacía cuando, en
Ajustes → Generales, se utiliza un desplazamiento numérico
—por ejemplo, UTC+2— en lugar de una zona horaria geográfica,
como Europe/Madrid.

Aunque el aviso incluye la clase is-dismissible, no hay ningún
código que recuerde el descarte. Por eso reaparece en cada carga de página
hasta corregir la causa.

Complicación encontrada en este sitio

En Ajustes → Generales, el desplegable de zona horaria solo
ofrece desplazamientos UTC —UTC+0, UTC+1, etc.—,
sin la lista habitual de ciudades y continentes.

Esto indica que la base de datos de zonas horarias de PHP,
timezone_identifiers_list(), no está completa en el servidor.
Se trata de un problema de configuración de PHP o del hosting, no de
WordPress en sí. Por eso no se pudo resolver simplemente seleccionando una
ciudad en el desplegable.

Solución aplicada: editar directamente wp_options mediante phpMyAdmin

Como el desplegable no permitía elegir una zona geográfica, se editaron
directamente las dos opciones implicadas en la tabla
wp_options:

  • timezone_string: antes estaba vacía; se estableció como
    Europe/Madrid.
  • gmt_offset: antes tenía el valor 2, reflejo de
    UTC+2 en horario de verano; se dejó vacío.

Pasos

  1. Entrar en phpMyAdmin, seleccionar la base de datos del sitio y abrir la
    tabla wp_options.
  2. Buscar la fila cuyo option_name sea
    timezone_string, editar option_value y
    establecerlo como Europe/Madrid.
  3. Buscar la fila cuyo option_name sea
    gmt_offset y vaciar su valor.
  4. Guardar los cambios y recargar el escritorio de WordPress.

El aviso desaparece de inmediato porque
trim($timezone_string) === '' deja de cumplirse.

Nota sobre falsas pistas descartadas

Antes de llegar a la causa real, se investigaron dos vías que no aplicaban
a este caso. Se mantienen como referencia por si el aviso reapareciera con
un texto ligeramente distinto:

  • El texto del aviso es casi idéntico al que muestra
    The Events Calendar (Modern Tribe/StellarWP), un plugin de
    calendario distinto con un nombre parecido. Ese plugin no está instalado
    en la web, pero en wp_options quedan opciones residuales
    con el prefijo tec_, como
    tec_ct1_migration_state y
    tec_timed_events_timezone_update_needed. Esto indica que
    estuvo instalado en algún momento y se desinstaló sin limpiar por
    completo la base de datos. Estas opciones no afectan al problema del
    MEC, pero podrían eliminarse en una limpieza futura.
  • Un mirror desactualizado del código de MEC Lite en GitHub, versión
    5.21.2 de 2021, no contenía el texto del aviso. Fue necesario conseguir
    el ZIP real de la versión 7.36.4 instalada para localizar el código
    exacto.

Un plugin propio de FAQs con lógica condicional para WordPress

Hace poco os contaba cómo monté Comandos Linux, un plugin propio con su tabla, su panel y su shortcode. Esta vez le tocaba a algo que llevaba tiempo queriendo hacer: una sección de preguntas frecuentes con lógica condicional, sin depender de un plugin de formularios de terceros

Valencia, 12/09/2026, G.B.
En un proyecto para una intranet usé Fluent Forms para montar unas FAQs con lógica condicional: el usuario elegía un tema y solo veía las preguntas de ese tema. Funciona muy bien, pero es una herramienta pensada para formularios complejos, y para una sección de FAQs en Indaga.net me sobraba casi todo. Así que hice lo de siempre aquí: quedarme solo con la pieza que necesito y montarla yo mismo.

El planteamiento

La «lógica condicional» que quería no es tan sofisticada como la de un constructor de formularios: no necesito árboles de decisión ni preguntas que dependan de respuestas anteriores. Lo que necesito es más simple y, para una FAQ, más que suficiente: el usuario elige una categoría (por ejemplo «WordPress» o «Facturación») y solo se muestran las preguntas de esa categoría. El resto permanece oculto hasta que cambia de categoría.

Con eso claro, el plugin se reduce a tres piezas: una tabla propia en la base de datos, un panel de administración para gestionar las preguntas, y un shortcode que pinta el selector y las preguntas en el front end.

La tabla

Cada pregunta se guarda con seis campos: categoria, pregunta, respuesta (admite HTML básico, para poder meter enlaces o negritas en la respuesta), orden (para decidir en qué posición aparece dentro de su categoría) y activo (para poder desactivar una pregunta sin borrarla). Nada de post types personalizados ni de meta fields sueltos: una tabla con dbDelta() en la activación, como en el resto de plugins que he ido publicando aquí.

El panel de administración

El formulario de alta y edición es sencillo: categoría (con autocompletado a partir de las categorías ya creadas, para no acabar con «Facturación», «facturacion» y «Facturación » conviviendo en la misma tabla), pregunta, respuesta con el editor visual de WordPress, orden y una casilla de activo. El listado de abajo tiene buscador por texto, filtro por categoría y paginación, para cuando la lista crezca y no quepa cómodamente en una sola pantalla.

Aquí me llevé un buen susto durante las pruebas: el formulario, al guardar, se quedaba la página a medio cargar, como congelada. La causa no tenía nada que ver con la base de datos ni con archivos grandes, sino con el orden de ejecución de WordPress: el guardado del formulario y la redirección posterior se ejecutaban dentro de la misma función que pinta el HTML del panel, y para ese punto WordPress ya ha enviado las cabeceras HTTP. Una redirección que llega tarde, simplemente, no llega. La solución fue mover todo el procesamiento del formulario a un enganche admin_init, que se ejecuta antes de que se imprima nada, y que la función de renderizado se limite a pintar. Detalle poco vistoso, pero de esos que conviene tener en la cabeza si escribís vuestros propios paneles de administración.

La lógica condicional, en el front end

Aquí está el motivo del plugin. Al insertar el shortcode, el plugin agrupa las preguntas por categoría y, si hay más de una, añade automáticamente unos botones de selección arriba del todo, empezando por un botón «Todas». Al pulsar sobre una categoría, un poco de JavaScript sin dependencias oculta el resto de preguntas y muestra solo las de esa categoría, sin recargar la página. Si solo existe una categoría, o si el shortcode se usa fijando una en concreto, el selector directamente no aparece: no tiene sentido mostrar un filtro con una sola opción.

A eso se suma un buscador de texto libre y una paginación, ambos también resueltos en el cliente: no hace falta ir a la base de datos cada vez que alguien escribe una letra o cambia de página, porque una sección de FAQs no suele tener miles de registros. Las preguntas se despliegan en acordeón al hacer clic, así que la página no se llena de texto de golpe.

El shortcode

Para insertarlo en cualquier entrada o página basta con:

indaga_faqs (entre corchetes, [ ])

Y admite varios parámetros para adaptarlo a cada sitio:

indaga_faqs categoria=»Facturación»

Muestra solo esa categoría, sin selector. Útil si queréis una página de FAQs dedicada a un único tema.

indaga_faqs mostrar_selector=»no»

Lista todas las categorías seguidas, sin botones de filtro.

indaga_faqs mostrar_buscador=»no»

Oculta la barra de búsqueda superior.

indaga_faqs por_pagina=»5″

Cambia cuántas preguntas se muestran por página (10 por defecto).

Por qué así

La misma filosofía de siempre: identificar qué parte de una herramienta de terceros es la que de verdad necesito, y quedarme solo con eso, escrito a medida, ligero y sin dependencias que arrastrar en cada actualización. Para una intranet con formularios complejos, Fluent Forms tiene todo el sentido. Para una sección de FAQs en un blog, con esto sobra.

Descargar plugin de mi cuenta de pCloud

Míralo en acción: Indaga FAQs

Plugin para importar CSV o Excel a cualquier tabla de WordPress

Un plugin propio para convertir un CSV o un Excel en filas listas para cualquier tabla de la base de datos, con dos modos: inserción directa o script SQL descargable

Valencia, 10/09/2026, G.B.
Cada vez que necesito poblar una tabla propia —la de comandos Linux, la de frases aleatorias, la de FAQs— acababa haciendo lo mismo: montar el INSERT INTO línea a línea a partir de un listado o insertarlo manualmente en el panel de administración del plugin. Pero si necesito crear muchos registros a la vez, la cosa se hacía algo pesada. Así que he preparado un plugin genérico para no repetir ese trabajo: Importador CSV/Excel a Tabla, que sube un archivo y lo convierte en inserciones para la tabla que le indiques.

Qué hace

El plugin añade una página en Herramientas → Importador CSV/Excel. Desde ahí se sube un archivo .csv o .xlsx y se indican tres cosas:

  • El nombre real de la tabla destino, con su prefijo (por ejemplo, wp_fa_frases)
  • Las columnas destino, separadas por comas y en el mismo orden que las columnas del archivo (sin incluir id si es autoincremental)
  • Si la primera fila es cabecera, para que se ignore

Y después hay que elegir el modo:

  • Insertar directamente en la base de datos, fila a fila, con un resumen de cuántas se han insertado, cuántas han fallado y cuántas se han descartado por no tener suficientes columnas
  • Generar un script SQL descargable, con el INSERT INTO completo, para revisarlo y ejecutarlo yo mismo en phpMyAdmin en vez de dejar que el plugin toque la tabla directamente

Cómo lee los archivos

Con el CSV no hay mucho misterio: se parsea con fgetcsv(), detectando automáticamente si el delimitador es coma, punto y coma o tabulador, y respetando el BOM UTF-8 que añaden Excel o LibreCalc al exportar.

El XLSX es más delicado porque, en el fondo, es un ZIP con varios XML dentro. En vez de tirar de una librería de terceros como PhpSpreadsheet, lo abro con ZipArchive y leo directamente sharedStrings.xml (donde Excel guarda los textos) y sheet1.xml (donde guarda la estructura de celdas) con SimpleXML. Las dos son extensiones nativas de PHP, así que sigo sin añadir ninguna dependencia externa al sitio.

Esto tiene límites que asumo: solo lee la primera hoja del libro, no soporta celdas combinadas ni fórmulas, y las fechas se leen como el valor crudo de la celda, sin formatear. Para el uso que le voy a dar —listados sencillos de una sola hoja— es más que suficiente.

Por qué así

El plugin sigue la misma arquitectura que el resto de los que tengo en marcha: procesamiento del formulario enganchado a admin_init en lugar de dentro del callback del menú (para evitar el típico problema de cabeceras ya enviadas), patrón POST/Redirect/GET para no reenviar el formulario al recargar, y comprobación de nonce y de capacidad manage_options antes de tocar nada. debo comprobar si eso ralentiza el «pintado» del escritorio de WordPress.

La idea de fondo es la misma de siempre: cubrir una necesidad muy concreta —pasar una hoja de cálculo a SQL— con el menor peso posible y sin depender de plugins de terceros que no controlo.

Qué me falta por probar

Lo acabo de instalar y todavía no lo he puesto a prueba con datos reales: antes tengo que revisar la estructura de las tablas con las que voy a trabajar para asegurarme de que las columnas coinciden. Si funciona bien, es probable que añada una vista previa de las primeras filas del archivo antes de confirmar la importación, para pillar errores de columnas antes de que lleguen a la base de datos.

Descargar plugin de mi cuenta de pCloud

Mu-plugins en WordPress: qué son, para qué sirven y un ejemplo práctico

Valencia, 09/09/2026, G.B.
Si llevas un tiempo trasteando con WordPress, seguro que conoces la carpeta wp-content/plugins, donde viven los plugins que activas y desactivas desde el escritorio. Pero existe otra carpeta, mucho menos conocida, que merece la pena tener en el radar: wp-content/mu-plugins.

Qué son los mu-plugins

«MU» viene de Must-Use (de uso obligatorio). Son plugins que WordPress carga automáticamente en cada petición, sin que tengan que activarse desde el panel de administración. De hecho, ni siquiera aparecen en el listado habitual de plugins junto al botón de «Desactivar»: aparecen en una sección aparte, de solo lectura, llamada «Uso obligatorio».

Las diferencias clave respecto a un plugin normal son:

  • Se cargan siempre. No hay riesgo de que alguien los desactive sin querer desde el escritorio.
  • Se cargan antes que los plugins normales. Esto es importante si necesitas engancharte a hooks muy tempranos en el ciclo de carga de WordPress.
  • No admiten subcarpetas. WordPress solo lee los archivos .php que están directamente dentro de mu-plugins/, no los que están en subdirectorios (aunque sí puedes tener un archivo suelto que a su vez incluya otros ficheros desde una subcarpeta).
  • No tienen pantalla de ajustes activar/desactivar. Se gestionan a nivel de sistema de archivos, subiendo o quitando el .php correspondiente.

Para qué sirven

Los mu-plugins son la herramienta adecuada cuando necesitas que algo se ejecute siempre, de forma incondicional, sin dejarlo al albur de que un cliente, un editor o tú mismo en un despiste desactivéis un plugin importante. Casos típicos:

  • Funcionalidad crítica del sitio: cosas de las que depende el funcionamiento correcto y que no deberían poder desactivarse por error.
  • Ajustes de seguridad: por ejemplo, desactivar la edición de archivos desde el escritorio, forzar SSL en el admin, o bloquear XML-RPC.
  • Configuración de hosting o multisitio: en instalaciones multisitio son muy usados para forzar comportamientos comunes a toda la red, ya que se cargan en todos los sitios sin necesidad de activarlos uno a uno.
  • Pequeñas utilidades propias que no justifican un plugin completo con cabecera, opciones y demás, pero que quieres tener siempre disponibles.
  • Parches o correcciones rápidas, ideales cuando necesitas asegurarte de que algo se aplica sin depender de que nadie recuerde activarlo.

Cómo se instalan

No hay instalador ni pantalla de subida. El proceso es manual:

  1. Si la carpeta wp-content/mu-plugins/ no existe, se crea.
  2. Se sube el archivo .php directamente a esa carpeta.
  3. Listo. WordPress lo detecta y lo ejecuta en la siguiente petición, sin activar nada.

Ejemplo práctico: mostrar la fecha de última actualización en las entradas

Un caso de uso muy típico en un blog: mostrar al lector cuándo se actualizó por última vez un artículo, además de (o en lugar de) la fecha de publicación. Es útil para dar señales de que el contenido se mantiene al día, algo que además ayuda en SEO.

Vamos a crear un mu-plugin que añada esa fecha automáticamente al principio del contenido de las entradas, solo cuando la fecha de modificación sea distinta a la de publicación (para no mostrar información redundante si el artículo nunca se ha tocado).

<?php
/**
* Plugin Name: Indaga – Last Updated Notice
* Description: Displays the last updated date on blog posts.
* Version: 1.0
* Author: Guillermo Beltrán Pilato
*/

// Exit if this file is loaded directly.
if ( ! defined( ‘ABSPATH’ ) ) {
exit;
}

/**
* Prepends the last updated notice to the post content.
*/
function indaga_show_last_updated_notice( $content ) {

// Only run on single posts, within the main content loop.
if ( ! is_single() || ! in_the_loop() || ! is_main_query() ) {
return $content;
}

$published_date = get_the_date( ‘U’ );
$modified_date = get_the_modified_date( ‘U’ );

// If the difference is less than a day, we consider there was no
// real update (avoids false positives from minor resaves).
if ( ( $modified_date – $published_date ) < DAY_IN_SECONDS ) {
return $content;
}

$formatted_date = get_the_modified_date( ‘F j, Y’ );

$notice = sprintf(
‘<p class=»indaga-last-updated-notice»><em>Last updated: %s</em></p>’,
esc_html( $formatted_date )
);

return $notice . $content;
}
add_filter( ‘the_content’, ‘indaga_show_last_updated_notice’ );

Descargar plugin desde mi cuenta de pCloud

Feeds, sitemap y una cabecera que sobra: pequeña auditoría de un sitio WordPress con curl

curl

Valencia, 08/09/2026, G.B.
Hay comandos que uno lanza casi por rutina y que, sin embargo, acaban destapando cosas interesantes. La semana pasada me tocó revisar el sitemap nativo de uno de mis sitios en WordPress y, de paso, aproveché para repasar algo que tenía pendiente desde hace tiempo: los feeds del sitio y una cabecera HTTP que no debería estar ahí.

Qué es el feed de un sitio en WordPress

El feed es una versión del contenido en XML —normalmente RSS 2.0, aunque WordPress también sabe hablar Atom— pensada para que lectores de noticias, agregadores o scripts consuman las entradas sin tener que «leer» el HTML de la página. Es una de esas piezas del núcleo de WordPress que llevan ahí desde siempre y que casi nadie mira hasta que algo deja de funcionar (a mí me pasó hace poco con un gadget de Blogger de terceros que dejó de refrescar entradas porque un Disallow: */feed/ en el robots.txt se lo estaba impidiendo).

Lo interesante es que WordPress no genera un único feed, sino uno por cada «vista» relevante del sitio:

  • Feed general, con todas las entradas: /feed/
  • Feed de comentarios de todo el sitio: /comments/feed/
  • Feed de una categoría: /category/nombre-categoria/feed/
  • Feed de una etiqueta: /tag/nombre-etiqueta/feed/
  • Feed de un autor: /author/nombre-autor/feed/
  • Feed de comentarios de una entrada concreta: /nombre-entrada/feed/
  • Feed de una búsqueda: /?s=termino&feed=rss2
  • Feed de un tipo de contenido personalizado, si el CPT lo soporta: /tipo-contenido/feed/

Y todavía hay una capa más: dentro de cada uno de esos ámbitos, el formato de salida se puede forzar con el parámetro ?feed=, con hasta cuatro variantes: rss2 (el que usa WordPress por defecto), rss (RSS 0.92, un formato ya legado), atom y rdf (RSS 1.0). En total, contando ámbitos y formatos, un sitio WordPress típico expone bastantes más «puertas de entrada» XML de las que uno recuerda a primera vista.

El sitemap no es un feed

Conviene no confundir los feeds con el sitemap. Desde WordPress 5.5 (2020), el núcleo genera automáticamente un sitemap XML nativo en /wp-sitemap.xml, sin necesidad de plugins de SEO. No es RSS ni Atom: es un índice que agrupa entradas, páginas, taxonomías y usuarios en sub-sitemaps independientes (wp-sitemap-posts-post-1.xml, wp-sitemap-users-1.xml, etc.), pensado para que los buscadores rastreen el sitio de forma eficiente.

Para comprobar rápidamente si el sitemap responde bien, sin descargarlo entero, uso el comando curl:

curl -I https://midominio.com/wp-sitemap.xml

El -I fuerza una petición HEAD: solo pide las cabeceras HTTP de la respuesta, no el cuerpo. Es la forma más rápida de saber si una URL existe, qué código de estado devuelve y qué tipo de contenido sirve, sin gastar ancho de banda en el XML completo.

Lo que las cabeceras cuentan (y lo que delatan)

Al lanzar ese curl -I contra uno de mis sitios, obtuve algo así (he sustituido la versión de PHP que aparecía por X.X.XX en el código que adjunto por motivos de seguridad):

HTTP/2 200
content-type: text/xml;charset=UTF-8
date: Fri, 04 Sep 2026 06:42:34 GMT
server: Apache
x-powered-by: PHP/X.X.XX

Todo correcto salvo un detalle: x-powered-by: PHP/X.X.XX está anunciando al mundo, con número de versión exacto, qué motor PHP corre detrás. No es una vulnerabilidad en sí misma, pero sí información gratuita para cualquiera que esté buscando qué CVEs probar contra esa versión concreta. Es el tipo de cabecera que no aporta nada al visitante legítimo y sí algo a quien está reconociendo el terreno.

La buena noticia es que server: Apache ya venía sin número de versión, así que esa parte no hacía falta tocarla.

Cómo quitar la cabecera con php.ini

Yo tengo creado el archivo php.ini (o .user.ini) propio en el directorio del sitio. El mío ya incluía algunos ajustes habituales:

upload_max_filesize = 128M
post_max_size = 128M
max_execution_time = 300
memory_limit = 512M
zlib.output_compression = 1
zlib.output_compression_level = 9

Simplemente basta con añadir una línea más:

expose_php = Off

Tras guardar el archivo -y en algunos casos esperar unos minutos a que el hosting recargue la configuración- un nuevo curl -I me ha confirmado que la cabecera x-powered-by había desaparecido. Un cambio de una sola línea, pero de esos que conviene no dejar pendientes.

Resumiendo

  • Un sitio WordPress puede exponer más de media docena de feeds distintos, cada uno con hasta cuatro formatos posibles.
  • El sitemap nativo (/wp-sitemap.xml) es una pieza distinta, propia del núcleo desde la versión 5.5.
  • curl -I es la herramienta de cabecera rápida para auditar cualquier URL sin descargar su contenido.
  • Si tu hosting lo permite, expose_php = Off en tu php.ini o .user.ini es un cambio mínimo con un beneficio de seguridad nada despreciable.

Hooks, actions y filters en WordPress: la diferencia que todo el mundo confunde

Valencia, 05/09/2026, G.B.
Si llevas un tiempo tocando el functions.php de WordPress o escribiendo plugins, seguro que te has encontrado con add_action() y add_filter() mil veces. Pero cuando hay que explicar la diferencia entre ambos, es fácil quedarse en un «bueno, son parecidos». No lo son tanto, y entender bien la diferencia te ahorra bugs raros el día menos pensado.

Hook es el concepto general

Lo primero es aclarar la terminología. En español solemos traducir hook como «gancho», y es el término general que engloba a los otros dos: cualquier punto del código de WordPress donde puedes «engancharte» para ejecutar tu propia función. Ese término general se divide en dos tipos:

  • Actions (acciones)
  • Filters (filtros)

Todo lo demás son matices de estos dos tipos.

Actions: «haz algo aquí»

Una acción es un punto del código donde WordPress simplemente dice: «en este momento ha pasado algo, si quieres, ejecuta tu función». No espera que le devuelvas nada.

// Se ejecuta cada vez que se publica una entrada
add_action( 'publish_post', function( $post_id ) {
    wp_mail( 'admin@midominio.com', 'Nueva entrada publicada', 'ID: ' . $post_id );
});

Aquí no hay ningún dato que recoger de vuelta. La función hace su trabajo (enviar un correo, escribir un log, mostrar algo en pantalla) y punto.

Filters: «toma este dato, modifícalo y devuélvemelo»

Un filtro, en cambio, existe porque WordPress necesita un valor para seguir funcionando: un título, un contenido, un booleano que decide qué hacer. Tu función recibe ese valor, opcionalmente lo cambia, y tiene que devolverlo siempre.

// Añade un emoji al final de cada título
add_filter( 'the_title', function( $title ) {
    return $title . ' 🚀';
});

Si te olvidas del return, rompes el filtro para todo el sitio — es el error clásico de quien empieza con filtros viniendo de acciones.

La tabla que lo resume

ActionFilter
¿Qué hace?Ejecuta código en un punto concretoModifica un dato y lo devuelve
¿Recibe un valor?Opcionalmente, como parámetrosSí, el dato a modificar
¿Debe devolver algo?NoSiempre
Ejemplo típicoEnviar un email al publicar una entradaCambiar el texto de un título

Una forma rápida de identificarlos si estás leyendo código fuente de WordPress: si ves do_action(...), es un hook de tipo acción; si ves apply_filters(...), es un filtro. Y add_action() / add_filter() son las funciones que usas tú para «engancharte» a esos puntos.

Un ejemplo real: desactivar el editor de bloques

El otro día leí un artículo de Ayudawordpress.com (Fernando Tellado) sobre cómo desactivar el editor de bloques de WordPress (Gutenberg) para desactivarlo en una instalación multisitio. El filtro que se usa es un buen ejemplo de por qué tiene que ser un filter y no una action:

add_filter( 'use_block_editor_for_post_type', '__return_false' );
add_filter( 'use_widgets_block_editor', '__return_false' );

WordPress necesita saber, para cada tipo de contenido, si debe cargar el editor de bloques o el clásico — y eso es exactamente un booleano que hay que devolver. Por eso no podría implementarse como acción: una acción no tiene forma de decirle a WordPress «no, usa esto otro en su lugar». Necesita el mecanismo de ida y vuelta que ofrece el filtro.

Por qué merece la pena tenerlo claro

Más allá de la teoría, esto tiene una consecuencia práctica muy directa: si alguna vez te preguntas «¿por qué mi función no está haciendo efecto?», una de las primeras cosas que hay que revisar es si te has enganchado a una acción cuando en realidad necesitabas un filtro (o viceversa). Es un error tonto, pero muy común, y entender la diferencia de raíz evita perder tiempo depurando algo que en realidad es un fallo de concepto, no de sintaxis.

Adiós a Ivory Search: cómo he puesto un buscador propio en el menú de INDAGA.net

Valencia, 02/09/2026, G.B.
Desde hace tiempo, el buscador de INDAGA.net dependía de un plugin llamado Ivory Search. Funcionaba bien… hasta que ha dejado de hacerlo. Empezó a fallar sin motivo aparente, y en vez de perder horas depurando un plugin de terceros, decidí hacer lo que suelo hacer con este tipo de problemas: prescindir de él y montar algo propio, ligero y sin dependencias externas. Aquí os cuento el proceso completo, con sus vueltas incluidas.

El punto de partida

WordPress trae de serie un buscador nativo perfectamente funcional: el clásico ?s= que consulta directamente la base de datos a través de WP_Query. No necesita ningún plugin. Lo único que hacía falta era darle un sitio visible en la web, concretamente un icono en el menú de navegación que, al pulsarlo, desplegara el cuadro de búsqueda.

Como ya tengo un child theme de Twenty Seventeen (Indaga Child lo he llamado) con código PHP propio, la solución más limpia era añadir el buscador ahí mismo, aprovechando los hooks que ofrece WordPress para modificar el menú.

El código: un filtro y un poco de CSS

WordPress permite enganchar contenido extra a cualquier menú mediante el filtro wp_nav_menu_items. Con esto añadí un elemento más a la lista del menú: un botón con el icono de lupa (usando Dashicons, la librería de iconos que trae WordPress) y, justo debajo, el formulario de búsqueda nativo oculto por defecto:

add_filter('wp_nav_menu_items', 'indaga_add_search_to_menu', 10, 2);
function indaga_add_search_to_menu($items, $args) {
    if (isset($args->theme_location) && $args->theme_location === 'top') {
        $search_html  = '<li>';
        $search_html .= '<button type="button" aria-expanded="false" aria-controls="menu-search-form" aria-label="Abrir buscador">';
        $search_html .= '<span class="dashicons dashicons-search"></span>';
        $search_html .= '</button>';
        $search_html .= '<div id="menu-search-form">';
        $search_html .= get_search_form(false);
        $search_html .= '</div>';
        $search_html .= '</li>';

        $items .= $search_html;
    }
    return $items;
}

Este filtro lo añadí directamente al functions.php de mi child theme, junto al resto de funciones personalizadas que ya tenía (como el aviso de tiempo de lectura o la notificación por correo al publicar). Es el mismo archivo donde centralizo todo el código propio del tema.

El CSS se encarga de que el formulario permanezca oculto hasta que se pulsa el icono. Para el comportamiento de abrir y cerrar (toggle), en vez de meter el JavaScript directamente en el footer, preferí crear un archivo aparte: assets/js/menu-search-toggle.js, dentro de la carpeta del child theme. Mantener el JS en su propio archivo, en vez de incrustarlo en línea, hace que sea más fácil de mantener y que el navegador pueda cachearlo por separado.

document.addEventListener('DOMContentLoaded', function () {
    var toggleBtn = document.querySelector('.search-toggle-btn');
    var searchForm = document.querySelector('.menu-search-form');

    if (!toggleBtn || !searchForm) return;

    toggleBtn.addEventListener('click', function () {
        var isOpen = searchForm.classList.toggle('is-open');
        toggleBtn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
        if (isOpen) {
            var input = searchForm.querySelector('input[type="search"]');
            if (input) input.focus();
        }
    });

    document.addEventListener('click', function (e) {
        if (!searchForm.contains(e.target) && !toggleBtn.contains(e.target)) {
            searchForm.classList.remove('is-open');
            toggleBtn.setAttribute('aria-expanded', 'false');
        }
    });
});

Para que WordPress cargue ese archivo, hace falta encolarlo correctamente, también desde functions.php, en vez de simplemente enlazarlo a mano:

add_action('wp_enqueue_scripts', 'indaga_enqueue_search_toggle_script');
function indaga_enqueue_search_toggle_script() {
    wp_enqueue_script(
        'indaga-menu-search-toggle',
        get_stylesheet_directory_uri() . '/assets/js/menu-search-toggle.js',
        array(),
        wp_get_theme()->get('Version'),
        true
    );
}

Usar wp_get_theme()->get('Version') como número de versión del script tiene una ventaja práctica: cada vez que subo cambios al JS y actualizo la versión del child theme, WordPress invalida automáticamente la caché del navegador para ese archivo, evitando que se quede una versión antigua cacheada, tal y como ya hacía con el CSS.

El primer obstáculo: identificar el menú correcto

WordPress diferencia entre el nombre visible de un menú (el que ve el administrador en el panel, en mi caso «Principal (2)») y su theme_location, que es el identificador interno que usa el tema para saber dónde pintarlo. Son cosas distintas, y confundirlas es un error habitual.

Como Twenty Seventeen solo registra dos ubicaciones —top (menú superior) y social (redes sociales)— y mi child theme no añade ninguna ubicación propia, bastaba con usar top en el filtro. Lo confirmé revisando directamente el código fuente del tema en su repositorio oficial, sin necesidad de tocar nada en el servidor.

El segundo obstáculo: el icono invisible

Con el código subido, el HTML del icono aparecía correctamente en el código fuente de la página (lo comprobé viendo el código fuente del navegador), pero visualmente no se veía nada en el menú. Aquí es donde toca ser metódico:

¿Está el HTML presente? Sí, confirmado.

¿Puede ser la caché? Casi seguro. Uso Autoptimize para combinar y minificar CSS y JS, y si no se regenera bien después de subir cambios, el navegador sigue sirviendo la versión antigua.

¿Y si el icono está ahí pero es «invisible» por color? Bingo. El icono de la lupa (Dashicons) no heredaba el color blanco del resto del menú, así que se confundía con el fondo. Solo se veía al pasar el ratón por encima, porque el estado hover sí forzaba un cambio de color.

Intentar arreglarlo fijando color: #ffffff en el botón no fue suficiente: el pseudo-elemento que dibuja el icono de la fuente Dashicons no siempre hereda el color tan limpiamente como cabría esperar, sobre todo cuando hay otras reglas CSS con más peso de por medio. La solución definitiva y más robusta fue añadir directamente un fondo oscuro al botón en el CSS personalizado del tema:

button {
    background: #000000;
    border: none;
    cursor: pointer;
    padding: 8px 12px;
    line-height: 1;
    color: #ffffff;
}

button .dashicons {
    color: inherit;
    font-size: 20px;
    width: 20px;
    height: 20px;
}

Con el fondo negro y el icono en blanco, el contraste queda garantizado sin depender de si el navegador hereda bien o mal el color de un pseudo-elemento de fuente.

El resultado

Un buscador funcional, integrado en el menú «Principal (2)», que usa exclusivamente el motor de búsqueda nativo de WordPress. Sin plugins de terceros que mantener, sin sorpresas de compatibilidad, y con el código totalmente bajo mi control para poder ajustarlo cuando quiera.

Recursos Plugin v2.0: unificando dos plugins en uno

plugin recursos

Valencia, 29/08/2026, G.B.
Uno de esos proyectos que llevaba tiempo rondándome era simplificar el mantenimiento de dos plugins de WordPress que, con el tiempo, habían acabado haciendo cosas muy parecidas. La solución: fusionarlos en uno solo, Recursos Plugin v2.0, y de paso aprovechar para resolver algunos problemas técnicos que arrastraba desde hacía tiempo.

Por qué unificar

Mantener dos plugins con funcionalidades solapadas significa duplicar esfuerzo cada vez que hay que tocar algo: dos bases de código que revisar, dos sitios donde aplicar el mismo parche, dos superficies de error. Unificarlos en un único plugin reduce esa carga de mantenimiento y deja un código más coherente y fácil de razonar.

Como en el resto de mis plugins, la premisa se mantiene: cero dependencias externas. Todo el código es PHP nativo, sin librerías de terceros que puedan quedar desactualizadas o introducir vulnerabilidades.

El reto técnico: búsqueda vía GET

La búsqueda de recursos funciona con parámetros en la URL (GET) en lugar de POST. Esto tiene una ventaja clara para el usuario: los resultados de búsqueda son enlazables y compartibles. Puedes copiar la URL después de buscar y enviarla directamente a alguien, o guardarla en marcadores, y al abrirla se reproduce la misma búsqueda.

Para los formularios que sí requieren envío de datos, apliqué el patrón POST/Redirect/GET: el formulario se envía por POST, el servidor procesa la petición y responde con una redirección a una URL GET. Esto evita el clásico problema de que, al recargar la página después de enviar un formulario, el navegador pregunte si quieres «reenviar los datos» —o peor, los reenvíe sin avisar duplicando la acción.

El arreglo de compatibilidad con MySQL

Este plugin hereda el mismo ajuste que ya había aplicado en mi plugin de Autores: cambiar la longitud de las claves de índice a VARCHAR(191).

¿Por qué 191 y no 255? Según lo que he consultado 191 es un límite práctico impuesto por MySQL 5.7 cuando se usa el juego de caracteres utf8mb4 (necesario para soportar emojis y caracteres Unicode completos). Con utf8mb4, cada carácter puede ocupar hasta 4 bytes, y el límite de InnoDB para un índice es de 767 bytes en total. Eso deja un máximo de 191 caracteres para columnas indexadas antes de que MySQL lance un error.

Es uno de esos detalles que no se nota hasta que un día, sin previo aviso, la creación de una tabla falla con un mensaje críptico. Ya solucionado, y de forma consistente en todos mis plugins.

Instrucciones rápidas (desde el panel de control del plugin)

  1. Rellena el formulario de abajo y pulsa «Añadir Recurso» para crear uno nuevo.
  2. Pulsa «Editar» en la tabla para modificar un recurso existente.
  3. Pulsa «Eliminar» para borrarlo (se pedirá confirmación).
  4. Para mostrar los recursos en una página o entrada, usa el shortcode mostrar_recursos (entre corchetes [ ])
    (con buscador y paginación).
  5. Para mostrar solo los 4 recursos más recientes, usa el shortcode ultimos_recursos (entre corchetes [ ])

    Ejemplo con el shortcode para mostrar los últimos recursos añadidos:

    Mega
    Servicio con sede en Nueva Zelanda que destaca por su cifrado de extremo a extremo por defecto (zero-knowledge): ni siquiera Mega puede acceder al contenido de tus archivos. Ofrece 20 GB gratuitos de entrada.
    Dropbox
    Uno de los pioneros del sector, conocido por su sincronización robusta y fiable entre dispositivos. Ofrece funciones interesantes de recuperación de versiones anteriores de archivos y buena integración con apps de terceros.
    OneDrive
    Servicio de Microsoft integrado de forma nativa en Windows y en la suite Office 365. Cómodo si ya trabajas en el ecosistema Microsoft, con edición colaborativa directa en Word, Excel y PowerPoint online.
    pCloud
    Almacenamiento en la nube con sede en Suiza, con opción de pago único de por vida (en lugar de suscripción mensual) y cifrado adicional opcional (pCloud Crypto). Buena alternativa para quien busca privacidad reforzada.

Resultado

Después de un tiempo probándolo en producción, el plugin unificado funciona correctamente. Un solo punto de mantenimiento, búsquedas enlazables, sin reenvíos de formulario molestos, y compatibilidad garantizada con instalaciones MySQL 5.7. Os dejo el enlace de descarga del plugin, por si álguien quiere probarlo:

Descargar plugin (en formato .zip) de mi cuenta de pCloud

Plugin de WordPress para frases aleatorias

Valencia, 29/08/2026, G.B.
Tal y comenté recientemente en este post, aquí va el plugin de WordPress para frases aleatorias. Os paso también el archivo.php.

Recordad que para subir un plugin a WordPress (que no esté en el repositorio, como es el caso), hay que crear una carpeta con el mismo nombre del archivo .php, comprimirlo en .zip y subirlo por FTP o S FTP (mejor esto último) al apartado del escritorio de WordPress «Subir plugin».

Aquí va e código de la primera versión del plugin. Si no consigues verlo correcatente en este post, descarga el archivo frases-aleatorias.zip de mi cuenta de pCloud e instálalo en WordPRess – > Plugins.

<?php
/**
* Plugin Name: Frases Aleatorias Widget
* Description: Widget, shortcode

y panel de administración para gestionar frases (añadir, editar, eliminar). Cada frase incluye enlace de traducción automática al castellano. Sin dependencias externas.
* Version: 2.0
* Author: Puntocomunica.com
*/

if ( ! defined( ‘ABSPATH’ ) ) {
exit; // Salida directa no permitida.
}

define( ‘FA_TABLA’, ‘fa_frases’ ); // se le añadirá el prefijo de WP al usarla

/* ==========================================================
* INSTALACIÓN: crea la tabla y la rellena con las 50 frases
* la primera vez que se activa el plugin.
* ========================================================== */
function fa_activar_plugin() {
global $wpdb;
$tabla = $wpdb->prefix . FA_TABLA;
$charset_collate = $wpdb->get_charset_collate();

$sql = «CREATE TABLE $tabla (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
texto TEXT NOT NULL,
autor VARCHAR(191) NOT NULL,
PRIMARY KEY (id)
) $charset_collate;»;

require_once ABSPATH . ‘wp-admin/includes/upgrade.php’;
dbDelta( $sql );

// Solo se siembran datos si la tabla está vacía (primera instalación)
$total = $wpdb->get_var( «SELECT COUNT(*) FROM $tabla» );
if ( (int) $total === 0 ) {
$frases_iniciales = array(
array( «He who blames others has a long way to go on his journey. He who blames himself is halfway there. He who blames no one has arrived.», «Chinese Proverb» ),
array( «The only way to do great work is to love what you do.», «Steve Jobs» ),
array( «In the middle of difficulty lies opportunity.», «Albert Einstein» ),
array( «It does not matter how slowly you go as long as you do not stop.», «Confucius» ),
array( «Fall seven times, stand up eight.», «Japanese Proverb» ),
array( «The journey of a thousand miles begins with a single step.», «Lao Tzu» ),
array( «Whether you think you can or you think you can’t, you’re right.», «Henry Ford» ),
array( «The best way to predict the future is to create it.», «Abraham Lincoln» ),
array( «Success is not final, failure is not fatal: it is the courage to continue that counts.», «Winston Churchill» ),
array( «Believe you can and you’re halfway there.», «Theodore Roosevelt» ),
array( «Do not go where the path may lead, go instead where there is no path and leave a trail.», «Ralph Waldo Emerson» ),
array( «The mind is everything. What you think you become.», «Buddha» ),
array( «Water which is too pure has no fish.», «Chinese Proverb» ),
array( «A smooth sea never made a skilled sailor.», «African Proverb» ),
array( «He that can have patience can have what he will.», «Benjamin Franklin» ),
array( «You miss 100% of the shots you don’t take.», «Wayne Gretzky» ),
array( «Life is what happens when you’re busy making other plans.», «John Lennon» ),
array( «The only impossible journey is the one you never begin.», «Tony Robbins» ),
array( «Not all those who wander are lost.», «J.R.R. Tolkien» ),
array( «It always seems impossible until it’s done.», «Nelson Mandela» ),
array( «Do what you can, with what you have, where you are.», «Theodore Roosevelt» ),
array( «If you want to go fast, go alone. If you want to go far, go together.», «African Proverb» ),
array( «The best time to plant a tree was 20 years ago. The second best time is now.», «Chinese Proverb» ),
array( «What lies behind us and what lies before us are tiny matters compared to what lies within us.», «Ralph Waldo Emerson» ),
array( «Turn your wounds into wisdom.», «Oprah Winfrey» ),
array( «Knowing yourself is the beginning of all wisdom.», «Aristotle» ),
array( «The unexamined life is not worth living.», «Socrates» ),
array( «He who has a why to live can bear almost any how.», «Friedrich Nietzsche» ),
array( «Out of difficulties grow miracles.», «Jean de La Bruyère» ),
array( «A journey of a thousand miles must begin with a single step.», «Lao Tzu» ),
array( «Even a small star shines in the darkness.», «Finnish Proverb» ),
array( «The bamboo that bends is stronger than the oak that resists.», «Japanese Proverb» ),
array( «Better to light a candle than curse the darkness.», «Chinese Proverb» ),
array( «When the winds of change blow, some build walls, others build windmills.», «Chinese Proverb» ),
array( «Fall down seven times, get up eight.», «Japanese Proverb» ),
array( «One who plants a tree knows they will not sit in its shade.», «Indian Proverb» ),
array( «The nail that sticks out gets hammered down.», «Japanese Proverb» ),
array( «A single arrow is easily broken, but not ten in a bundle.», «Japanese Proverb» ),
array( «He who has never failed has never tried anything new.», «African Proverb» ),
array( «Even the greatest of trees was once a nut who held its ground.», «American Proverb» ),
array( «The river that forgets its source will dry up.», «African Proverb» ),
array( «A bird does not sing because it has an answer, it sings because it has a song.», «Chinese Proverb» ),
array( «Patience is bitter, but its fruit is sweet.», «Aristotle» ),
array( «He who is not courageous enough to take risks will accomplish nothing in life.», «Muhammad Ali» ),
array( «The best preparation for tomorrow is doing your best today.», «H. Jackson Brown Jr.» ),
array( «Success usually comes to those who are too busy to be looking for it.», «Henry David Thoreau» ),
array( «Do not wait for the perfect moment, take the moment and make it perfect.», «Unknown» ),
array( «Great things are done by a series of small things brought together.», «Vincent van Gogh» ),
array( «Perseverance is not a long race; it is many short races one after the other.», «Walter Elliot» ),
array( «You cannot cross the sea merely by standing and staring at the water.», «Rabindranath Tagore» ),
);

foreach ( $frases_iniciales as $f ) {
$wpdb->insert( $tabla, array( ‘texto’ => $f[0], ‘autor’ => $f[1] ) );
}
}
}
register_activation_hook( __FILE__, ‘fa_activar_plugin’ );

/* ==========================================================
* FUNCIONES DE ACCESO A DATOS
* ========================================================== */
function fa_obtener_frases() {
global $wpdb;
$tabla = $wpdb->prefix . FA_TABLA;
return $wpdb->get_results( «SELECT id, texto, autor FROM $tabla ORDER BY id ASC» );
}

function fa_obtener_frases_json() {
$frases = fa_obtener_frases();
$salida = array();
foreach ( $frases as $f ) {
$salida[] = array( ‘texto’ => $f->texto, ‘autor’ => $f->autor );
}
return wp_json_encode( $salida );
}

/* ==========================================================
* RENDERIZADO EN PORTADA (widget y shortcode)
* Incluye enlace de traducción automática al castellano
* (Google Translate), generado por JS según la frase mostrada.
* ========================================================== */
function fa_render_markup_y_script( $instancia_id ) {
ob_start();
?>
<div id=»fa-widget-<?php echo esc_attr( $instancia_id ); ?>» class=»fa-widget» style=»font-family: Georgia, serif; padding: 16px; border-left: 4px solid #444; background: #f9f9f9;»>
<p class=»fa-texto» style=»font-style: italic; font-size: 16px; line-height: 1.5; margin: 0 0 8px 0;»></p>
<p class=»fa-autor» style=»text-align: right; font-weight: bold; margin: 0 0 8px 0;»></p>
<p style=»text-align: right; margin: 0 0 12px 0;»>
<a class=»fa-traducir» href=»#» target=»_blank» rel=»noopener noreferrer» style=»font-size: 13px; text-decoration: underline;»>Traducir al castellano</a>
</p>
<button class=»fa-btn» type=»button» style=»cursor: pointer; padding: 6px 14px; border: none; border-radius: 4px; background: #444; color: #fff; font-size: 14px;»>Otra frase</button>
</div>
<script>
(function() {
var frases = <?php echo fa_obtener_frases_json(); ?>;
var contenedor = document.getElementById(‘fa-widget-<?php echo esc_js( $instancia_id ); ?>’);
if (!contenedor || !frases.length) return;
var elTexto = contenedor.querySelector(‘.fa-texto’);
var elAutor = contenedor.querySelector(‘.fa-autor’);
var elTraducir = contenedor.querySelector(‘.fa-traducir’);
var elBtn = contenedor.querySelector(‘.fa-btn’);

function mostrarFraseAleatoria() {
var indice = Math.floor(Math.random() * frases.length);
var frase = frases[indice];
elTexto.textContent = ‘\u201C’ + frase.texto + ‘\u201D’;
elAutor.textContent = ‘\u2014 ‘ + frase.autor;
elTraducir.href = ‘https://translate.google.com/?sl=en&tl=es&text=’ + encodeURIComponent(frase.texto) + ‘&op=translate’;
}

elBtn.addEventListener(‘click’, mostrarFraseAleatoria);
mostrarFraseAleatoria();
})();
</script>
<?php
return ob_get_clean();
}

function fa_shortcode_frases_aleatorias() {
static $contador = 0;
$contador++;
return fa_render_markup_y_script( ‘sc-‘ . $contador );
}
add_shortcode( ‘frases_aleatorias’, ‘fa_shortcode_frases_aleatorias’ );

class FA_Widget_Frases extends WP_Widget {
public function __construct() {
parent::__construct(
‘fa_widget_frases’,
__( ‘Frase Aleatoria’, ‘frases-aleatorias’ ),
array( ‘description’ => __( ‘Muestra una frase célebre al azar, con enlace de traducción y botón para cambiarla.’, ‘frases-aleatorias’ ) )
);
}

public function widget( $args, $instance ) {
echo $args[‘before_widget’];
if ( ! empty( $instance[‘titulo’] ) ) {
echo $args[‘before_title’] . apply_filters( ‘widget_title’, $instance[‘titulo’] ) . $args[‘after_title’];
}
echo fa_render_markup_y_script( ‘widget-‘ . $this->id );
echo $args[‘after_widget’];
}

public function form( $instance ) {
$titulo = ! empty( $instance[‘titulo’] ) ? $instance[‘titulo’] : __( ‘Frase del día’, ‘frases-aleatorias’ );
?>
<p>
<label for=»<?php echo esc_attr( $this->get_field_id( ‘titulo’ ) ); ?>»><?php _e( ‘Título:’, ‘frases-aleatorias’ ); ?></label>
<input class=»widefat» id=»<?php echo esc_attr( $this->get_field_id( ‘titulo’ ) ); ?>» name=»<?php echo esc_attr( $this->get_field_name( ‘titulo’ ) ); ?>» type=»text» value=»<?php echo esc_attr( $titulo ); ?>»>
</p>
<?php
}

public function update( $new_instance, $old_instance ) {
$instance = array();
$instance[‘titulo’] = ( ! empty( $new_instance[‘titulo’] ) ) ? sanitize_text_field( $new_instance[‘titulo’] ) : »;
return $instance;
}
}
add_action( ‘widgets_init’, function () {
register_widget( ‘FA_Widget_Frases’ );
} );

/* ==========================================================
* PANEL DE ADMINISTRACIÓN: listar, añadir, editar, eliminar
* ========================================================== */
add_action( ‘admin_menu’, function () {
add_menu_page(
‘Frases Aleatorias’,
‘Frases Aleatorias’,
‘manage_options’,
‘fa-frases’,
‘fa_pagina_admin’,
‘dashicons-format-quote’,
58
);
} );

function fa_pagina_admin() {
if ( ! current_user_can( ‘manage_options’ ) ) {
return;
}

global $wpdb;
$tabla = $wpdb->prefix . FA_TABLA;

/* ———- Procesar acciones enviadas por formulario ———- */
if ( isset( $_POST[‘fa_accion’] ) && check_admin_referer( ‘fa_guardar_frase’, ‘fa_nonce’ ) ) {
$texto = isset( $_POST[‘fa_texto’] ) ? sanitize_textarea_field( wp_unslash( $_POST[‘fa_texto’] ) ) : »;
$autor = isset( $_POST[‘fa_autor’] ) ? sanitize_text_field( wp_unslash( $_POST[‘fa_autor’] ) ) : »;

if ( $texto !== » && $autor !== » ) {
if ( $_POST[‘fa_accion’] === ‘crear’ ) {
$wpdb->insert( $tabla, array( ‘texto’ => $texto, ‘autor’ => $autor ) );
echo ‘<div class=»notice notice-success is-dismissible»><p>Frase añadida.</p></div>’;
} elseif ( $_POST[‘fa_accion’] === ‘actualizar’ && ! empty( $_POST[‘fa_id’] ) ) {
$wpdb->update( $tabla, array( ‘texto’ => $texto, ‘autor’ => $autor ), array( ‘id’ => (int) $_POST[‘fa_id’] ) );
echo ‘<div class=»notice notice-success is-dismissible»><p>Frase actualizada.</p></div>’;
}
} else {
echo ‘<div class=»notice notice-error is-dismissible»><p>El texto y el autor no pueden estar vacíos.</p></div>’;
}
}

if ( isset( $_GET[‘fa_eliminar’] ) && check_admin_referer( ‘fa_eliminar_frase’ ) ) {
$wpdb->delete( $tabla, array( ‘id’ => (int) $_GET[‘fa_eliminar’] ) );
echo ‘<div class=»notice notice-success is-dismissible»><p>Frase eliminada.</p></div>’;
}

/* ———- ¿Estamos editando una frase? ———- */
$editando = null;
if ( isset( $_GET[‘fa_editar’] ) ) {
$editando = $wpdb->get_row( $wpdb->prepare( «SELECT * FROM $tabla WHERE id = %d», (int) $_GET[‘fa_editar’] ) );
}

$frases = fa_obtener_frases();
?>
<div class=»wrap»>
<h1>Frases Aleatorias</h1>
<p>Shortcode: <code>

</code> · También disponible como widget en Apariencia → Widgets.</p>

<h2><?php echo $editando ? ‘Editar frase’ : ‘Añadir nueva frase’; ?></h2>
<form method=»post» style=»max-width: 600px;»>
<?php wp_nonce_field( ‘fa_guardar_frase’, ‘fa_nonce’ ); ?>
<input type=»hidden» name=»fa_accion» value=»<?php echo $editando ? ‘actualizar’ : ‘crear’; ?>»>
<?php if ( $editando ) : ?>
<input type=»hidden» name=»fa_id» value=»<?php echo esc_attr( $editando->id ); ?>»>
<?php endif; ?>

<table class=»form-table»>
<tr>
<th><label for=»fa_texto»>Texto (inglés)</label></th>
<td><textarea name=»fa_texto» id=»fa_texto» rows=»3″ class=»large-text» required><?php echo $editando ? esc_textarea( $editando->texto ) : »; ?></textarea></td>
</tr>
<tr>
<th><label for=»fa_autor»>Autor</label></th>
<td><input type=»text» name=»fa_autor» id=»fa_autor» class=»regular-text» required value=»<?php echo $editando ? esc_attr( $editando->autor ) : »; ?>»></td>
</tr>
</table>

<?php submit_button( $editando ? ‘Actualizar frase’ : ‘Añadir frase’ ); ?>
<?php if ( $editando ) : ?>
<a href=»<?php echo esc_url( admin_url( ‘admin.php?page=fa-frases’ ) ); ?>» class=»button»>Cancelar edición</a>
<?php endif; ?>
</form>

<h2>Frases existentes (<?php echo count( $frases ); ?>)</h2>
<table class=»widefat striped»>
<thead>
<tr>
<th style=»width: 55%;»>Texto</th>
<th>Autor</th>
<th style=»width: 160px;»>Acciones</th>
</tr>
</thead>
<tbody>
<?php foreach ( $frases as $f ) : ?>
<tr>
<td><?php echo esc_html( $f->texto ); ?></td>
<td><?php echo esc_html( $f->autor ); ?></td>
<td>
<a href=»<?php echo esc_url( admin_url( ‘admin.php?page=fa-frases&fa_editar=’ . $f->id ) ); ?>»>Editar</a>
|
<a href=»<?php echo esc_url( wp_nonce_url( admin_url( ‘admin.php?page=fa-frases&fa_eliminar=’ . $f->id ), ‘fa_eliminar_frase’ ) ); ?>»
onclick=»return confirm(‘¿Eliminar esta frase?’);» style=»color: #b32d2e;»>Eliminar</a>
|
<a href=»https://translate.google.com/?sl=en&tl=es&text=<?php echo rawurlencode( $f->texto ); ?>&op=translate» target=»_blank» rel=»noopener noreferrer»>Traducir</a>
</td>
</tr>
<?php endforeach; ?>
<?php if ( empty( $frases ) ) : ?>
<tr><td colspan=»3″>Todavía no hay frases.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?php
}

Cómo crear un tema hijo en WordPress: el caso de Twenty Seventeen

Child Theme

Si mantienes un sitio en WordPress durante años, tarde o temprano te enfrentas a un problema clásico: has personalizado el CSS, quizás también el header.php, y cada actualización del tema te obliga a repetir esos cambios desde cero. La solución habitual es un tema hijo (o child theme), y en este artículo repaso el proceso completo que he seguido en Indaga.net, incluyendo las dudas que me han surgido por el camino (hacía mucho tiempo que no hacía esto…).

Comentar que todo el proceso sirve para cualquier tema o theme de WordPress, no solo para Twenty Seventeen.

¿Qué es un tema hijo y por qué usarlo?

Un tema hijo es un tema que hereda toda la funcionalidad y las plantillas de otro tema, el «padre», pero permite sobreescribir archivos concretos sin tocar el original. La gran ventaja es que las actualizaciones del tema padre no borran tus personalizaciones, siempre que estén en el sitio correcto.

En mi caso, el tema padre es Twenty Seventeen, y el objetivo principal era mantener un CSS personalizado sin perderlo cada vez que WordPress actualiza el tema.

Estructura mínima necesaria

Contra lo que podría parecer, no hace falta replicar toda la estructura de carpetas del tema padre. Con dos archivos en la raíz ya es suficiente para tener un tema hijo funcional:

wp-content/themes/tema-hijo/
├── style.css
└── functions.php

El stylee.css necesita una cabecera específica que le indica a WordPress cuál es el tema padre:

/*
 Theme Name:   Indaga Child
 Template:     twentyseventeen
 Version:      1.0.0
*/

Y el functions.php se encarga de encolar primero el CSS del padre y después el propio, para que las reglas personalizadas puedan sobreescribir las originales:

<?php
add_action( 'wp_enqueue_scripts', 'indaga_child_enqueue_styles' );
function indaga_child_enqueue_styles() {
    wp_enqueue_style( 'twentyseventeen-parent-style', get_template_directory_uri() . '/style.css' );
    wp_enqueue_style( 'indaga-child-style',
        get_stylesheet_directory_uri() . '/style.css',
        array( 'twentyseventeen-parent-style' ),
        wp_get_theme()->get('Version')
    );
}

Con esto ya se puede activar el tema hijo desde Apariencia → Temas.

Carpetas opcionales

Solo tiene sentido crearlas si de verdad se van a usar:

  • /assets/css/: para separar el CSS en varios archivos en lugar de amontonarlo todo en style.css.
  • /assets/js/: si se añade JavaScript propio.
  • /inc/: para dividir functions.php en varios archivos PHP si crece mucho.
  • /template-parts/: solo si se van a sobreescribir plantillas concretas del padre (la ruta y el nombre de archivo deben coincidir exactamente con los del tema original para que WordPress detecte la sustitución).
  • /languages/: si se traducen cadenas propias del tema hijo.

La duda del «AdSense desaparecido»

Al activar el tema hijo, me surgió una duda: si tuviera código de AdSense no aparecía en el sitio, y mi primer instinto fue pensar que si lo tuviera, tendría que copiar el header.php del padre al hijo para «recuperarlo».

Aquí está el matiz importante: WordPress hereda automáticamente los archivos de plantilla que el hijo no tiene. Si el tema hijo no incluye su propio header.php, WordPress usa sin más el header.php del tema padre, con todo su contenido, incluyendo cualquier código insertado directamente en él. Así que, en teoría, no debería haber ninguna pérdida solo por activar el hijo.

Antes de tocar nada, conviene descartar por orden:

  1. Caché activa. Con un plugin de caché como WP Fastest Cache, es fácil estar viendo una versión antigua de la página. Vaciar la caché y probar en incógnito resuelve muchos falsos problemas de este tipo
  2. Que el tema activo sea realmente el hijo
  3. Ver el código fuente de la página (Ctrl+U) para comprobar si el fragmento está presente en el HTML aunque no se vea (podría ser un bloqueador de anuncios del propio navegador, no un problema de plantilla)

En este caso concreto, el sitio todavía estaba en fase de revisión y por eso no se mostraban anuncios: no era un problema del tema hijo en absoluto.

¿Qué pasa si actualizo el tema padre?

Esta es probablemente la pregunta más importante de todo el proceso, y la respuesta no es igual para todos los archivos:

  • functions.php del tema hijo: totalmente a salvo. Es un archivo propio, en su propia carpeta, y una actualización del tema padre jamás lo toca.
  • header.php / footer.php editados directamente en el tema padre: aquí está el riesgo real. Si el código de AdSense (o cualquier otra personalización) se editó directamente en el header.php de Twenty Seventeen, una actualización del tema sustituye ese archivo por el original del repositorio, y esos cambios se pierden.

La solución es sencilla: copiar el header.php (y cualquier otro archivo modificado directamente en el padre) a la carpeta del tema hijo, manteniendo el mismo nombre. A partir de ese momento, la herencia de plantillas hace que WordPress use la versión del hijo en lugar de la del padre, y esos archivos quedan protegidos frente a futuras actualizaciones.

El detalle estético: la miniatura en blanco

Un último detalle menor pero visible: al crear el tema hijo desde cero, la miniatura que aparece en Apariencia → Temas queda en blanco. Esto es simplemente porque falta el archivo screenshot.png en la raíz del tema; WordPress no lo genera automáticamente.

Basta con subir una imagen (idealmente en proporción 4:3, por ejemplo 1200×900 px) con ese nombre exacto a la raíz del tema hijo. No requiere ninguna declaración adicional en style.css ni en functions.php; WordPress la detecta solo por estar ahí.

Así es como queda el mío:

child-theme-indaga

Resumen del proceso

  1. Crear style.css y functions.php en wp-content/themes/tema-hijo/ (en mi caso wp-content/themes/indaga/ al haber llamado así al tema hijo o child theme.
  2. Activar el tema hijo desde el escritorio de WordPress.
  3. Antes de dar por buena cualquier «desaparición» de contenido, descartar caché y confirmar el tema activo.
  4. Copiar a la carpeta del hijo cualquier archivo de plantilla (header.php, footer.php, etc.) que se haya editado directamente en el tema padre, para blindarlo frente a actualizaciones.
  5. Añadir un screenshot.png para que la miniatura del tema deje de aparecer en blanco.

Con estos pasos, el sitio queda preparado para recibir actualizaciones de Twenty Seventeen sin sobresaltos, manteniendo intactas todas las personalizaciones (que son bastantes…).