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
}

Por qué desactivar Disable Comments no basta para reabrir los comentarios en WordPress

comentarios wordpress

Valencia, 22/08/2026, G.B.
Hace unos días me encontré con un problema aparentemente sencillo que terminó siendo bastante más terco de lo esperado: quería volver a permitir comentarios en las entradas de este blog, y por más que lo intentaba, el formulario para escribir un comentario nuevo seguía sin aparecer. Los comentarios antiguos se veían perfectamente, pero no había forma de dejar uno nuevo.

El sospechoso obvio: Disable Comments

En algún momento había usado el plugin Disable Comments para bloquear los comentarios en ciertos tipos de contenido. Cuando decidí revertir eso, hice lo que parecía razonable:

  1. Entré en los ajustes del plugin y cambié el tipo de contenido (la taxonomía de entradas) para el que se bloqueaban los comentarios, dejando «Entradas» fuera del bloqueo.
  2. Guardé los cambios.
  3. Desactivé el plugin.

Nada. Los comentarios seguían sin poder crearse.

Pensando que quizás el plugin no se había desactivado del todo bien, fui un paso más allá:

  1. Lo desinstalé por completo, borrándolo del sitio.
  2. Vacié toda la caché, tanto la del plugin de caché del sitio como cualquier caché a nivel de servidor.

Seguía sin funcionar. En este punto ya no tenía sentido seguir sospechando del plugin: ni siquiera estaba instalado.

Reduciendo el problema

Revisé el archivo single.php del tema por si el formulario de comentarios se estaba omitiendo por algún motivo relacionado con la plantilla, pero el código era el estándar, sin nada raro. También descarté que hubiera algún hook personalizado interfiriendo en functions.php del tema hijo: nada relacionado con comments_open, save_post ni wp_insert_post_data.

Lo que sí encontré, consultando directamente la base de datos, fue esto: entradas ya publicadas, incluso una programada, tenían el campo comment_status en closed en la tabla wp_posts, a pesar de que el ajuste general de WordPress (Ajustes > Comentarios > «Permitir que la gente envíe comentarios en las entradas nuevas») estaba correctamente en abierto.

La explicación tiene sentido una vez se entiende cómo funciona WordPress internamente: el ajuste default_comment_status solo se aplica en el momento en que se crea la fila del post por primera vez. Si esa entrada se creó (aunque fuera como borrador) mientras el ajuste global estaba en cerrado, o mientras Disable Comments seguía activo bloqueando ese tipo de contenido, el valor closed queda grabado de forma permanente en esa fila concreta. Cambiar el ajuste general después no reescribe retroactivamente las entradas que ya existían; solo afecta a las que se creen a partir de ese momento.

Es decir, el problema nunca estuvo en el plugin en sí, sino en un dato ya escrito en la base de datos que el plugin dejó como herencia antes de desactivarse.

La solución: una consulta SQL

Con muchas entradas afectadas, corregirlas una a una desde el panel de WordPress (edición en lote, cambiando «Comentarios» a «Permitir») era viable pero lento. La solución rápida fue una actualización directa en la base de datos desde phpMyAdmin:

USE nombre_de_tu_base_de_datos;
UPDATE wp_posts SET comment_status = 'open' WHERE post_type = 'post' AND comment_status = 'closed';

Esta consulta actualiza de golpe el estado de comentarios de todas las entradas (post_type = 'post') que estuvieran cerradas, dejando fuera páginas y otros tipos de contenido. Antes de ejecutarla conviene hacer una copia de seguridad de la tabla wp_posts (tienes que poner tu prefijo de las tablas. Por defecto, si no lo has cambiado en la instalación de WordPress es wp_), y si se quiere comprobar el alcance antes de aplicar el cambio, un simple SELECT COUNT(*) FROM wp_posts WHERE post_type = 'post' AND comment_status = 'closed'; da el número exacto de entradas que se van a ver afectadas.

Conclusión

Cuando un plugin como Disable Comments actúa escribiendo valores directamente en la base de datos en lugar de aplicar solo un filtro en tiempo real, desactivarlo (o incluso desinstalarlo) no revierte esos cambios. Los ajustes generales de WordPress tampoco se aplican de forma retroactiva a entradas ya existentes. En estos casos, revisar directamente la base de datos con una consulta SQL es la forma más fiable de confirmar la causa real del problema y, de paso, la manera más rápida de solucionarlo cuando hay muchas entradas implicadas.

Función para calcular el tiempo de lectura de una entrada (post)

Valencia, 19/08/2026, G.B.
Si alguna vez has visto en un blog un texto como «5 min de lectura» justo encima del contenido de una entrada, seguramente te hayas preguntado cómo se calcula. La buena noticia es que no hace falta instalar ningún plugin para conseguirlo: con una función sencilla en el functions.php del tema hijo es suficiente.

La idea detrás del cálculo

El tiempo de lectura estimado se basa en un cálculo muy simple: se cuenta el número de palabras del contenido y se divide entre una velocidad media de lectura, normalmente entre 200 y 250 palabras por minuto. No es una ciencia exacta, pero da una idea útil y realista al lector sobre cuánto tiempo le va a llevar la entrada.

La función paso a paso

Lo primero es una función que calcule ese tiempo a partir del contenido del post:

function indaga_calculate_reading_time( $post_id = null ) {
  if ( ! $post_id ) {
    $post_id = get_the_ID();
  }
  $content = get_post_field( 'post_content', $post_id );
  $content = strip_shortcodes( $content );
  $content = wp_strip_all_tags( $content );
  preg_match_all( '/[\p{L}\p{N}]+/u', $content, $matches );
  $word_count = count( $matches[0] );
  $reading_time = ceil( $word_count / 200 );
  if ( $reading_time < 1 ) {
    $reading_time = 1;
  }
  return $reading_time;
}

Merece la pena detenerse en un par de detalles. Antes de contar palabras, se eliminan los shortcodes y las etiquetas HTML con strip_shortcodes() y wp_strip_all_tags(), para no contar código como si fuera texto. Y el conteo de palabras se hace con una expresión regular UTF-8 (preg_match_all con el patrón /[\p{L}\p{N}]+/u) en lugar de la función nativa str_word_count(), porque esta última no reconoce bien acentos ni la letra «ñ», algo imprescindible al trabajar con contenido en castellano.

Mostrarlo automáticamente en cada entrada

Con el cálculo ya resuelto, el siguiente paso es mostrarlo sin tener que tocar ninguna plantilla del tema. Para eso se engancha a un filtro que WordPress ya tiene previsto: the_content.

function indaga_add_reading_time_to_content( $content ) {
  if ( is_single() && in_the_loop() && is_main_query() && 'post' === get_post_type() ) {
    $reading_time = indaga_calculate_reading_time();
    $notice = '<p class="indaga-reading-time">Tiempo de lectura: ' . $reading_time . ' min</p>';
    $content = $notice . $content;
  }
  return $content;
}
add_filter( 'the_content', 'indaga_add_reading_time_to_content' );

Las tres comprobaciones dentro del if son las que marcan la diferencia entre que esto funcione bien o dé problemas: is_single() asegura que solo se muestre en la página individual de una entrada, in_the_loop() y is_main_query() evitan que el aviso aparezca duplicado en widgets, feeds o bucles secundarios, y la comprobación del tipo de contenido lo limita a entradas de blog, dejando fuera páginas u otros tipos de contenido.

Personalizar el estilo

El aviso se genera con la clase CSS indaga-reading-time, así que basta con añadir unas líneas al style.css del tema hijo para darle el aspecto deseado, por ejemplo:

.indaga-reading-time {
  font-style: italic;
  color: #666;
  margin-bottom: 1em;
}

En resumen

Con menos de treinta líneas de código, sin dependencias externas y sin necesidad de un plugin adicional, es posible añadir una funcionalidad que mejora la experiencia de lectura y encaja perfectamente con la filosofía de mantener el sitio ligero y bajo control propio.

Nota: Descargar de mi cuenta pCloud el código completo (en formato .txt)

Plugin Autores revisado y actualizado (versión 3.9)

autores-plugin

Valencia, 18/08/2026, G.B.
El pequeño plugin que hice para insertar publicaciones, Autores, me fallaba en Puntocomunica.com y en este sitio Indaga.net. Tenía que crear la tabla «a mano» desde phpMyAdmin.

En la web para la que hice este plugin funcionaba perfectamente. La diferencia es que esa web tiene la última versión de mariaDB y las otras dos, las mías, una versión más antigua de MySQL. Además, la seguridad no estaba del todo «afinada«.  Ahora creo que sí. Para mostrarlo hay que insertar el shortcode (entre corchetes[ ], como todos los shortcodes): autores_form

Una muestra:

Añadir nueva publicación

No tiene permiso para agregar autores.

Publicaciones listadas

Terry Pratchett
A Stroke of the Pen. The lost Stories
Libros - 2023
Manuel Vicent
Verás el cielo abierto
Libros - 2005
Rosa Montero
Historia del rey transparente
Libros - 2005
Terry Pratchett
Pyramids
Libros - 1992
Terry Pratchett
Equal Rites
Libros - 1987

Este es el enlace de descarga de mi cuenta pCloud: descargar autores-plugin.zip

Eliminar hipervínculos de títulos en WordPress: Cómo crear un plugin sencillo

eliminar_enlaces_en_titulo

Valencia, 17/08/2026, G.B.
Los títulos con enlaces en WordPress pueden ser un problema cuando necesitas mantener la consistencia visual o evitar navegación innecesaria. Si administras un directorio o catálogo de productos y tus títulos contienen HTML con hipervínculos, te voy a mostrar cómo crear un plugin personalizado para eliminarlos automáticamente.

El problema

Durante un tiempo, estuve poniendo el enlace en el título a las entradas de la parte del directorio de Indaga.net. No sé, en ese momento me pareció una buena idea… Con el tiempo comprobé que era un error, por lo que he decidido quitar el código HTML en el título de las entradas afectadas., las cuales, además, deben tener una etiqueta (tag) específica: en mi caso «directorio».

Un ejemplo de título de una entrada con el enlace incluido, el que quiero borrar, para que veas a qué me refiero:

<a href="https://www.ejemplo.com" target="_blank" rel="nofollow">Título de la entrada</a>

Esto no solo afecta la apariencia, sino que también puede generar problemas de SEO y navegación indeseada. ¿La solución? Un plugin personalizado que elimine estos enlaces automáticamente.

La expresión regular

La clave está en esta expresión regular: /<a[^>]*>(.*?)<\/a>/i

Desglosándola:

  • <a[^>]*> – Captura la etiqueta de apertura con cualquier atributo
  • (.*?) – Extrae el texto dentro (no greedy)
  • <\/a> – Captura el cierre de la etiqueta
  • i – Ignora mayúsculas/minúsculas

Crear el plugin en 3 pasos

Paso 1: Crear el archivo del plugin

Accede a tu servidor mediante FTP,  SFTP o el administrador de archivos de tu hosting, y crea un nuevo archivo:

/wp-content/plugins/eliminar_enlaces_en_titulo.php

Más rápido:
También puedes crear una carpeta con el mismo nombre del archivo .php e incluirlo ahí. Luego la comprimes como .zip y navegas en tu escritorio de WordPress a  Plugins: Añadir plugin. Subes el archivo .zip, lo instalas y lo activas.

Paso 2: Agregar el código

Copia y pega el siguiente código:

<?php
/**
 * Plugin Name: Eliminar Enlaces en Título
 * Description: Elimina hipervínculos del título solo en entradas con tag "directorio"
 * Version: 1.0
 * Author: Guillermo Beltrán Pilato
 * Text Domain: eliminar-enlaces-directorio
 */

function eliminar_enlaces_de_titulos_directorio($titulo, $id = 0) {
    // Solo aplicar si el post tiene el tag "directorio"
    if ($id && has_tag('directorio', $id)) {
        return preg_replace('/<a[^>]*>(.*?)<\/a>/i', '$1', $titulo);
    }
    return $titulo;
}

add_filter('the_title', 'eliminar_enlaces_de_titulos_directorio', 10, 2);
?>

Paso 3: Activar el plugin

  1. Ve a Plugins → Plugins instalados en tu panel de WordPress
  2. Busca «Eliminar Enlaces en Tag Directorio»
  3. Haz clic en Activar

¿Cómo funciona?

El plugin actúa como un filtro invisible que:

  • Detecta todas las entradas con el tag «directorio»
  • Elimina el código HTML de los enlaces en los títulos
  • Conserva el texto original sin el hipervínculo
  • No modifica la base de datos (solo afecta la visualización)

Ejemplo:

AntesDespués
<a href=»…»>Traductor online Deepl</a>Traductor online Deepl

Personalización

Usar un tag diferente

Si tu tag no se llama «directorio», simplemente cambia el nombre en la función:

if ($id && has_tag('mi-tag', $id)) {

Aplicar a múltiples tags

if ($id && (has_tag('directorio', $id) || has_tag('catalogo', $id))) {

Filtrar por tipo de entrada

Si solo quieres que funcione en páginas en lugar de entradas:

if ($id && has_tag('directorio', $id) && get_post_type($id) === 'page') {

Ventajas de este enfoque

  • Automático: No necesitas hacer nada manualmente
  • Seguro: No modifica la base de datos
  • Flexible: Solo afecta las entradas que especifiques
  • Reversible: Desactiva el plugin y vuelve al estado anterior
  • Lightweight: Código mínimo, sin impacto en rendimiento

Actualizar títulos existentes en la base de datos

Si prefieres que los cambios se guarden permanentemente en la base de datos, puedes usar esta consulta SQL (con copia de seguridad previa):

UPDATE wp_posts 
SET post_title = REGEXP_REPLACE(post_title, '<a[^>]*>(.*?)</a>', '$1')
WHERE post_type = 'post' AND post_title LIKE '%<a%';

Conclusión

Con este plugin personalizado, tienes control total sobre cómo se muestran los títulos en tu sitio. Es especialmente útil para directorios, catálogos y sitios donde importas contenido regularmente. Fácil de instalar, sin configuración necesaria, y totalmente gratuito.

Bonus Track

Enlaces a páginas para aprender sobre expresiones regulares:

RegexOne: Tutorial interactivo donde practicas directamente en el navegador con ejercicios progresivos. Muy práctico para aprender haciendo

Regular Expressions Info: Aunque el sitio principal está en inglés, tiene una sección traducida en español y es muy exhaustivo. Excelente como referencia

MDN Web Docs de Mozilla – Expresiones regulares es probablemente tu mejor opción. Es completo, autorizado, tiene ejemplos claros y está bien traducido al español. Cubre desde lo básico hasta conceptos avanzados.

Cómo añadir un honeypot a un formulario de contacto de Contact Form 7

Valencia, 14/08/2024, G.B.
Para ampliar la información del artículo Cómo evitar el spam en los formularios de Contact Form 7 en WordPress, vamos ahora a ver cómo añadir un «honeypot» o «tarro de miel» a un formulario de Contact Form 7 para engañar a los bots y evitar así el maldito spam, en la medida de lo posible…

Son tres sencillos pasos:

1) Añadir el campo oculto al formulario

En el editor de Contact Form 7, dentro de la pestaña Formulario, añade este campo en cualquier parte del formulario (por ejemplo, justo después del campo de nombre):

[text website-url]

Esto genera un campo de texto normal llamado website-url. El nombre es arbitrario, pero conviene que suene «atractivo» para un bot (website, url, phone-number… campos que suelen rellenar automáticamente).

2) Ocultarlo con CSS

Añade esta regla a la hoja de estilos del tema (o al Personalizador → CSS adicional):

.wpcf7-form p:has(input[name="website-url"]) {
position: absolute;
left: -9999px;
top: -9999px;
}

Si prefieres no depender de :has() (por compatibilidad con navegadores muy antiguos), puedes envolver el campo en un <span> con una clase propia desde el editor de CF7 en modo HTML:

<span class="cf7-honeypot">[text website-url]</span>

y luego:

.cf7-honeypot {
position: absolute;
left: -9999px;
top: -9999px;
}

Ambas opciones son válidas; la primera es más simple si no necesitas soportar navegadores muy antiguos.

3) Validar el envío en functions.php

Contact Form 7 incluye desde la versión 5.1 el filtro wpcf7_spam, pensado justo para esto:

add_filter( 'wpcf7_spam', function ( $spam, $submission ) {
if ( $spam ) {
return $spam; // ya marcado como spam por otra regla, no seguimos
}

$data = $submission->get_posted_data();

if ( ! empty( $data['website-url'] ) ) {
return true; // el honeypot llegó relleno: es un bot
}

return $spam;
}, 10, 2 );

Con esto, si el campo website-url llega con cualquier contenido, Contact Form 7 marca el envío como spam internamente y lo bloquea antes de que se envíe el correo, sin mostrar ningún error confuso al bot (simplemente no pasa nada, como sería el comportamiento esperado desde su perspectiva).

Nota importante sobre el name del campo

Evita nombres como honeypot o trap: los bots más elaborados ya los reconocen y los saltan. Cuanto más «normal» parezca el nombre del campo (address, company, fax-number), más probable es que un bot lo rellene sin sospechar nada.

Qué es honeypot

honeypot

Valencia, 1508/2026 G.B.
Un «honeypot» en WordPress es una técnica anti-spam sencilla que consiste en añadir un campo oculto a un formulario (como uno de contacto o comentarios) que los usuarios humanos no ven, pero que los bots sí detectan y rellenan automáticamente.

Cómo funciona:

1. Se añade un campo extra al formulario (por ejemplo, uno llamado «web» o «nombre_completo»)
2. Ese campo se oculta visualmente mediante CSS (`display:none`, posición fuera de pantalla, opacidad 0, etc.), nunca con `type=»hidden»`, porque muchos bots ya lo detectan
3. Un usuario real no lo ve, así que lo deja vacío
4. Un bot que rellena todos los campos automáticamente sí lo completa.
5. En el servidor, al procesar el envío, se comprueba: si ese campo llega con contenido, se descarta el mensaje como spam (o se marca como sospechoso)

Ventajas:

– No requiere librerías externas ni servicios de terceros (encaja con tu enfoque de soluciones ligeras y sin dependencias)
– No añade fricción al usuario real (a diferencia de un captcha).
– Fácil de implementar en formularios propios o incluso combinarlo con Contact Form 7 mediante un hook

Limitaciones

– Los bots más sofisticados que ejecutan JavaScript y respetan el CSS pueden evitarlo
– Conviene combinarlo con otras medidas (verificación de tiempo mínimo de envío, nonce, rate limiting) para mayor robustez

Si quieres, te preparo un ejemplo de implementación en PHP puro para un formulario propio o específicamente como complemento a la prevención de spam que ya tienes en Contact Form 7

Cómo evitar el spam en los formularios de Contact Form 7 en WordPress

Valencia, 13/08/2026, G.B.
Cualquiera que administre un sitio en WordPress con un formulario de contacto conoce el problema: tarde o temprano, la bandeja de entrada empieza a llenarse de mensajes automáticos, ofertas de SEO milagroso o enlaces sospechosos. Contact Form 7, uno de los plugins de formularios más usados del ecosistema WordPress, no incorpora protección antispam por defecto, así que esa tarea recae en quien lo instala. La buena noticia es que evitarlo no requiere grandes conocimientos técnicos, sino combinar bien un puñado de técnicas.

Por qué llega el spam a un formulario de contacto

El spam en formularios no suele venir de personas, sino de bots que rastrean la web en busca de campos <form> y <input> para rellenarlos automáticamente. Cuantos más elementos «adivinables» tenga el formulario (nombre, email, mensaje), más fácil es para un bot completarlo sin intervención humana. Por eso las soluciones eficaces no se basan en dificultar la vida al usuario real, sino en tender trampas o barreras que solo detectan a las máquinas.

El método más eficaz y menos invasivo: el honeypot

La técnica del honeypot consiste en añadir al formulario un campo invisible para las personas pero visible para los bots, que recorren el HTML sin ejecutar el CSS que lo oculta. Si ese campo llega relleno al servidor, se descarta el envío sin más. Contact Form 7 permite implementarlo fácilmente combinando un campo de texto oculto con CSS y una validación en functions.php que rechace el envío si ese campo no está vacío. Es la opción que menos fricción añade, porque no exige nada al usuario legítimo.

Un honeypot («tarro de miel» en castellano) existe en el HTML, así que cualquier bot que analice el código lo detecta como un campo más a rellenar.
Está oculto para las personas, normalmente con CSS (display: none o posicionándolo fuera de la pantalla), así que un usuario real jamás lo ve ni lo rellena, por lo que debería llegar siempre vacío cuando el envío lo hace una persona.

Un bot que rastrea formularios no «ve» el CSS, solo el HTML crudo, así que interpreta ese campo como uno legítimo y lo completa automáticamente (a menudo con texto basura, o incluso su propia URL). En el servidor, la lógica es simple: si ese campo llega con algo escrito, el envío se descarta sin más, porque solo una máquina pudo haberlo rellenado.

Es una trampa en el sentido literal: no impide que el bot rellene el formulario, sino que aprovecha su propio comportamiento automático para delatarlo. Por eso es tan eficaz y tan poco invasivo a la vez —no le pide nada extra al usuario real, solo tiende una trampa que solo un bot puede pisar.

reCAPTCHA: la barrera más conocida

Google reCAPTCHA (en su versión v3, invisible) es la integración nativa de Contact Form 7 y sigue siendo una de las defensas más sólidas frente a bots más sofisticados. Se configura desde el propio panel de Contact Form 7, introduciendo la clave del sitio y la clave secreta que genera Google. Su ventaja es que analiza el comportamiento del visitante en segundo plano, sin mostrar los típicos rompecabezas de «selecciona las imágenes con semáforos», lo que evita perjudicar la conversión.

Validación estricta de campos

Otra capa de protección, complementaria a las anteriores, es endurecer la validación de los campos del formulario: exigir que el campo de email tenga un formato válido, limitar la longitud del mensaje, o bloquear la presencia de etiquetas HTML o enlaces en campos que no deberían contenerlos (el nombre, por ejemplo). Contact Form 7 admite expresiones regulares personalizadas en sus campos, lo que permite filtrar patrones típicos del spam antes de que el mensaje llegue siquiera a enviarse.

Filtrado adicional con Akismet

Si el sitio ya usa Akismet para proteger los comentarios de WordPress, se puede aprovechar el mismo servicio para los formularios: existen extensiones de Contact Form 7 que envían cada envío a Akismet para su análisis antes de reenviarlo por correo. Es un paso extra útil cuando el volumen de spam sigue siendo alto pese al honeypot y el reCAPTCHA.

Qué evitar

Conviene ser prudente con los bloqueos por IP y las limitaciones agresivas de frecuencia de envío: son medidas que, mal calibradas, terminan bloqueando a usuarios legítimos con más facilidad que a los propios bots, que suelen rotar de IP constantemente. Tampoco es necesario desactivar el formulario ni añadir capas de fricción visibles si con un honeypot y reCAPTCHA v3 ya se resuelve la mayor parte del problema.

En resumen

La combinación más razonable para un formulario de Contact Form 7 en WordPress es: honeypot como primera barrera invisible, reCAPTCHA v3 como segunda capa de análisis de comportamiento, y validación de campos para cerrar el paso a los patrones más evidentes de spam. Con estos tres elementos bien configurados, la gran mayoría de los envíos automáticos quedan filtrados sin que el visitante real note ninguna diferencia al escribir su mensaje.

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…).