Cuaderno de Ideas: un plugin ligero para tus anotaciones privadas en WordPress

Valencia, 28/09/2026, G.B.
A la hora de gestionar publicaciones y proyectos de contenidos, la dispersión de notas es un dolor de cabeza. Solemos acumular anotaciones en aplicaciones externas, documentos sueltos o notas rápidas que terminan perdiéndose. Para resolver esto, decidí crear un cuaderno de ideas privado integrado dentro del propio panel de administración de WordPress, permitiéndome preparar borradores, visualizarlos de forma privada y exportarlos a distintos formatos (HTML, Markdown o TPL/BBCode para foros como phpBB) de manera 100% nativa.

¿Por qué desarrollar este plugin?

Aunque en el repositorio de WordPress existen bastantes extensiones para tomar notas, la gran mayoría sufren de dos grandes inconvenientes: o cargan librerías pesadas en JavaScript que ralentizan el panel, o guardan la información en servidores de terceros con modelos de suscripción.

Con este plugin conseguimos un entorno de trabajo rápido, privado e integrado en la base de datos de WordPress. Nos permite trabajar sin dependencias externas y transformar cualquier borrador en el código necesario para publicar en blogs, repositorios como GitHub o comunidades en phpBB.

Requisitos y arquitectura técnica

Para mantener el desarrollo ligero y libre de conflictos de compatibilidad, la arquitectura del plugin se apoya en tres pilares básicos:

  • Cero dependencias externas: Funciona única y exclusivamente con la API nativa de WordPress (Custom Post Types, Custom Taxonomies y Meta Boxes). No requiere frameworks ni scripts externos.
  • Seguridad por diseño: Implementa tokens de seguridad (nonces) para validar formularios, comprobación estricta de permisos de usuario (edit_posts), restricción de vista previa pública y sanitización de campos de texto antes de guardarlos en la base de datos.
  • Exportación y vista previa en el servidor: Genera vistas dinámicas para copiar el código transformado al portapapeles y permite la previsualización del post en el frontend de forma estrictamente privada (devolviendo un error 403 de acceso denegado si intenta acceder un usuario no autenticado).

El código completo del plugin

Crea la carpeta /wp-content/plugins/cuaderno-ideas/ y guarda dentro el archivo cuaderno-ideas.php con el siguiente código:

<?php
/**
 * Plugin Name: Cuaderno de Ideas Privado
 * Description: Diario de ideas privado para gestión de publicaciones con vista previa privada y exportación a HTML, Markdown y TPL (BBCode).
 * Version:     1.2.0
 * Author:      INDAGA.net
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit; // Evitar acceso directo
}

/**
 * 1. REGISTRO DEL CUSTOM POST TYPE Y TAXONOMÍAS NATIVAS
 */
function cip_registrar_post_type() {
    $labels = array(
        'name'               => 'Ideas',
        'singular_name'      => 'Idea',
        'menu_name'          => 'Cuaderno Ideas',
        'add_new'            => 'Añadir Idea',
        'add_new_item'       => 'Añadir Nueva Idea',
        'edit_item'          => 'Editar Idea',
        'new_item'           => 'Nueva Idea',
        'view_item'          => 'Ver Idea Privada',
        'search_items'       => 'Buscar Ideas',
        'not_found'          => 'No se encontraron ideas',
    );

    $args = array(
        'labels'             => $labels,
        'public'             => false, // No se indexa en listados públicos
        'publicly_queryable' => true,  // Permite renderizar la vista individual
        'show_ui'            => true,  // Visible en el panel de administración
        'show_in_menu'       => true,
        'query_var'          => true,
        'rewrite'            => array( 'slug' => 'idea-privada' ),
        'capability_type'    => 'post',
        'has_archive'        => false,
        'hierarchical'       => false,
        'menu_position'      => 20,
        'menu_icon'          => 'dashicons-lightbulb',
        'supports'           => array( 'title', 'editor' ),
    );

    register_post_type( 'cip_idea', $args );

    // Categorías de Ideas
    register_taxonomy( 'cip_categoria', 'cip_idea', array(
        'label'        => 'Categorías de Ideas',
        'rewrite'      => false,
        'hierarchical' => true,
        'show_ui'      => true,
    ) );

    // Etiquetas de Ideas
    register_taxonomy( 'cip_etiqueta', 'cip_idea', array(
        'label'        => 'Etiquetas de Ideas',
        'rewrite'      => false,
        'hierarchical' => false,
        'show_ui'      => true,
    ) );
}
add_action( 'init', 'cip_registrar_post_type' );

/**
 * 2. SEGURIDAD: BLOQUEAR VISTA PÚBLICA A USUARIOS NO AUTENTICADOS
 */
function cip_proteger_vista_privada() {
    if ( is_singular( 'cip_idea' ) ) {
        if ( ! is_user_logged_in() || ! current_user_can( 'edit_posts' ) ) {
            wp_die(
                'Acceso restringido: Esta idea pertenece a un cuaderno de notas privado.',
                'Acceso Denegado',
                array( 'response' => 403 )
            );
        }
    }
}
add_action( 'template_redirect', 'cip_proteger_vista_privada' );

/**
 * 3. SUBMENÚ Y PÁGINA DE INSTRUCCIONES / CONFIGURACIÓN EN EL PANEL
 */
function cip_agregar_menu_instrucciones() {
    add_submenu_page(
        'edit.php?post_type=cip_idea',
        'Instrucciones y Uso',
        'Instrucciones',
        'manage_options',
        'cip-instrucciones',
        'cip_render_pagina_instrucciones'
    );
}
add_action( 'admin_menu', 'cip_agregar_menu_instrucciones' );

function cip_render_pagina_instrucciones() {
    $active_tab = isset( $_GET['tab'] ) ? sanitize_text_field( $_GET['tab'] ) : 'instrucciones';
    ?>
    <div class="wrap">
        <h1>Cuaderno de Ideas Privado</h1>

        <h2 class="nav-tab-wrapper">
            <a href="?post_type=cip_idea&page=cip-instrucciones&tab=instrucciones" class="nav-tab <?php echo $active_tab === 'instrucciones' ? 'nav-tab-active' : ''; ?>">
                Instrucciones
            </a>
            <a href="?post_type=cip_idea&page=cip-instrucciones&tab=ficha" class="nav-tab <?php echo $active_tab === 'ficha' ? 'nav-tab-active' : ''; ?>">
                Ficha Técnica
            </a>
        </h2>

        <div class="tab-content" style="margin-top:20px; background:#fff; padding:20px; border:1px solid #ccc; max-width:900px;">
            <?php if ( $active_tab === 'instrucciones' ) : ?>
                <h2>Guía de Uso del Cuaderno de Ideas</h2>
                <hr>
                <ol>
                    <li><strong>Crear una Idea:</strong> Dirígete a <code>Cuaderno Ideas &gt; Añadir Idea</code>. Escribe el título y el cuerpo del borrador utilizando el editor nativo.</li>
                    <li><strong>Ver Idea como Post Privado:</strong> Haz clic en el botón «Ver Idea Privada» en el panel lateral para abrir la vista individual de la entrada en el frontend (solo accesible si has iniciado sesión).</li>
                    <li><strong>Categorización:</strong> Asigna categorías y etiquetas en la barra lateral para organizar tus temas.</li>
                    <li><strong>Gestión de Estado:</strong> En el panel lateral «Detalles y Estado de la Idea», define la prioridad (Baja, Media, Alta), marca la casilla si ya ha sido publicada, e indica el lugar y fecha de publicación.</li>
                    <li><strong>Exportar Contenido:</strong> En el panel inferior del editor («Exportar Contenido»), selecciona entre las versiones <strong>HTML</strong>, <strong>Markdown</strong> o <strong>TPL (BBCode para phpBB)</strong> y utiliza los botones para copiar directamente el código al portapapeles.</li>
                </ol>
            <?php else : ?>
                <h2>Ficha del Plugin</h2>
                <hr>
                <p>Diario de ideas privado para gestión interna de publicaciones, vista previa privada e exportación a múltiples formatos de marcado.</p>
                
                <p><strong>Acceso:</strong> Cuaderno Ideas &gt; Instrucciones (<code>wp-admin/edit.php?post_type=cip_idea&amp;page=cip-instrucciones</code>)</p>
                
                <p><strong>Alias / Shortcodes:</strong> No aplica (es un plugin exclusivamente de administración interna privada).</p>
                
                <p><strong>Dependencias:</strong> Ninguna (100% nativo mediante Custom Post Types y API de WordPress).</p>
                
                <p><strong>Seguridad:</strong> Validación mediante nonces (<code>wp_verify_nonce</code>), restricción de vista previa en frontend (<code>template_redirect</code> con respuesta 403 para usuarios no autorizados), verificación de capacidades (<code>edit_posts</code>), sanitización al guardar (<code>sanitize_text_field</code>) y escapado estricto en la salida (<code>esc_textarea</code>, <code>esc_attr</code>, <code>esc_url</code>).</p>
            <?php endif; ?>
        </div>
    </div>
    <?php
}

/**
 * 4. CREACIÓN DE CAMPOS PERSONALIZADOS (META BOXES)
 */
function cip_agregar_meta_boxes() {
    add_meta_box(
        'cip_detalles_idea',
        'Detalles y Estado de la Idea',
        'cip_render_meta_box',
        'cip_idea',
        'side',
        'high'
    );

    add_meta_box(
        'cip_exportar_idea',
        'Exportar Contenido',
        'cip_render_export_box',
        'cip_idea',
        'normal',
        'high'
    );
}
add_action( 'add_meta_boxes', 'cip_agregar_meta_boxes' );

function cip_render_meta_box( $post ) {
    wp_nonce_field( 'cip_guardar_metas', 'cip_nonce' );

    $prioridad   = get_post_meta( $post->ID, '_cip_prioridad', true ) ?: 'media';
    $publicado   = get_post_meta( $post->ID, '_cip_publicado', true );
    $donde       = get_post_meta( $post->ID, '_cip_donde_publicado', true );
    $fecha_pub   = get_post_meta( $post->ID, '_cip_fecha_publicado', true );
    $permalink   = get_permalink( $post->ID );

    ?>
    <?php if ( 'publish' === $post->post_status ) : ?>
        <p>
            <a href="<?php echo esc_url( $permalink ); ?>" target="_blank" class="button button-secondary" style="width:100%; text-align:center;">
                <span class="dashicons dashicons-visibility" style="vertical-align:middle; margin-top:-2px;"></span> Ver Idea Privada
            </a>
        </p>
        <hr>
    <?php endif; ?>

    <p>
        <label for="cip_prioridad"><strong>Prioridad:</strong></label><br>
        <select name="cip_prioridad" id="cip_prioridad" style="width:100%;">
            <option value="baja" <?php selected( $prioridad, 'baja' ); ?>>Baja</option>
            <option value="media" <?php selected( $prioridad, 'media' ); ?>>Media</option>
            <option value="alta" <?php selected( $prioridad, 'alta' ); ?>>Alta</option>
        </select>
    </p>
    <hr>
    <p>
        <label for="cip_publicado">
            <input type="checkbox" name="cip_publicado" id="cip_publicado" value="1" <?php checked( $publicado, '1' ); ?>>
            <strong>¿Publicado fuera?</strong>
        </label>
    </p>
    <p>
        <label for="cip_donde_publicado"><strong>¿Dónde se ha publicado?</strong></label><br>
        <input type="text" name="cip_donde_publicado" id="cip_donde_publicado" value="<?php echo esc_attr( $donde ); ?>" style="width:100%;" placeholder="Ej: Blog principal, Medium...">
    </p>
    <p>
        <label for="cip_fecha_publicado"><strong>Fecha de publicación:</strong></label><br>
        <input type="date" name="cip_fecha_publicado" id="cip_fecha_publicado" value="<?php echo esc_attr( $fecha_pub ); ?>" style="width:100%;">
    </p>
    <?php
}

/**
 * 5. GUARDADO SEGURO DE DATOS (SANITIZACIÓN)
 */
function cip_guardar_metas( $post_id ) {
    if ( ! isset( $_POST['cip_nonce'] ) || ! wp_verify_nonce( $_POST['cip_nonce'], 'cip_guardar_metas' ) ) {
        return;
    }
    if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
        return;
    }
    if ( ! current_user_can( 'edit_post', $post_id ) ) {
        return;
    }

    if ( isset( $_POST['cip_prioridad'] ) ) {
        update_post_meta( $post_id, '_cip_prioridad', sanitize_text_field( $_POST['cip_prioridad'] ) );
    }

    $publicado = isset( $_POST['cip_publicado'] ) ? '1' : '0';
    update_post_meta( $post_id, '_cip_publicado', $publicado );

    if ( isset( $_POST['cip_donde_publicado'] ) ) {
        update_post_meta( $post_id, '_cip_donde_publicado', sanitize_text_field( $_POST['cip_donde_publicado'] ) );
    }

    if ( isset( $_POST['cip_fecha_publicado'] ) ) {
        update_post_meta( $post_id, '_cip_fecha_publicado', sanitize_text_field( $_POST['cip_fecha_publicado'] ) );
    }
}
add_action( 'save_post', 'cip_guardar_metas' );

/**
 * 6. MOTOR DE EXPORTACIÓN (HTML, MARKDOWN, TPL/BBCODE)
 */
function cip_render_export_box( $post ) {
    $content = $post->post_content;
    $title   = $post->post_title;

    $markdown = "# " . $title . "\n\n" . strip_tags( $content );

    $bbcode = "[b]" . $title . "[/b]\n\n";
    $bbcode .= wp_strip_all_tags( $content );

    ?>
    <p>Copia directamente la idea convertida al formato de destino:</p>
    <div style="margin-bottom:15px;">
        <button type="button" class="button" onclick="document.getElementById('cip_exp_html').select(); document.execCommand('copy');">Copiar HTML</button>
        <button type="button" class="button" onclick="document.getElementById('cip_exp_md').select(); document.execCommand('copy');">Copiar Markdown</button>
        <button type="button" class="button" onclick="document.getElementById('cip_exp_tpl').select(); document.execCommand('copy');">Copiar TPL / BBCode</button>
    </div>

    <h4>HTML Limpio:</h4>
    <textarea id="cip_exp_html" style="width:100%; height:80px;" readonly><?php echo esc_textarea( wpautop( $content ) ); ?></textarea>

    <h4>Markdown (.md):</h4>
    <textarea id="cip_exp_md" style="width:100%; height:80px;" readonly><?php echo esc_textarea( $markdown ); ?></textarea>

    <h4>TPL / phpBB (BBCode):</h4>
    <textarea id="cip_exp_tpl" style="width:100%; height:80px;" readonly><?php echo esc_textarea( $bbcode ); ?></textarea>
    <?php
}

Explicación del código de forma sencilla

Para entender la lógica tras el plugin, podemos resumir su funcionamiento en cinco partes clave:

Custom Post Type privado pero consultable

Utilizamos register_post_type configurando 'public' => false pero activando 'publicly_queryable' => true. Esto nos permite generar un enlace individual para visualizar la entrada como si fuera un post publicado estándar dentro de nuestro tema gráfico, pero sin aparecer en buscadores ni en los listados generales del sitio.

Protección de la vista privada en el servidor

Para asegurar que nadie ajeno lea nuestras anotaciones, la función cip_proteger_vista_privada() se ejecuta en el hook template_redirect. Si un visitante no autenticado (o sin permisos de edición) intenta abrir la URL directa de una idea, el servidor interrumpe la carga y devuelve una pantalla de error 403 (Acceso Denegado).

Panel de Instrucciones integrado

Siguiendo la estructura limpia que usamos en nuestros desarrollos, se añade una página dentro del menú del plugin (Cuaderno Ideas > Instrucciones) con pestañas dinámicas que recogen la guía de uso y la ficha técnica con los detalles de seguridad y accesos.

Metadatos y control de estado

Mediante add_meta_box creamos un panel lateral en el editor para fijar la prioridad de la idea, indicar si ya ha sido publicada fuera, la fecha y el medio de destino. Al guardar, el proceso valida un código nonce anti-CSRF y limpia los datos recibidos mediante sanitize_text_field.

Motor de exportación al vuelo

El panel inferior convierte el título y el cuerpo del borrador a tres formatos limpios. Para el HTML aplica la función wpautop() para estructurar los párrafos; para Markdown añade la sintaxis básica de encabezados; y para TPL/phpBB traduce el formato a etiquetas BBCode tipo [b]. Incluye además botones en JavaScript nativo para copiar el resultado con un solo clic.

Si quieres probarlo, puedes descargarlo desde Control de plugins.

De Ivory Search al motor nativo: cómo crear tu propio buscador de WordPress seguro, ligero y sin plugins externos

Valencia, 23/09/2026, G.B.
La búsqueda interna es una de las piezas clave en cualquier sitio web con una buena cantidad de contenidos, categorías y enlaces. Sin embargo, mantener un buscador funcional en WordPress sin cargar el sitio de dependencias o scripts superfluos puede llegar a ser complicado.

Durante mucho tiempo estuve utilizando en el sitio el conocido plugin Ivory Search. Cumplía su función, pero tras las últimas actualizaciones comenzó a fallar hasta que dejó de funcionar del todo. Como solución provisional, opté por sustituirlo por un buscador integrado directamente en la barra de navegación del menú (proceso que ya os comenté en un post anterior aquí en Indaga.net). Aunque funcionaba (y sigue funcionando) correctamente para búsquedas rápidas, faltaba la flexibilidad de disponer de una caja de búsqueda limpia, personalizable y reutilizable mediante shortcode en cualquier página, entrada o widget.

La solución por la que me he decidido ha sido desarrollar un  plugin propio en un único archivo .php: 100% nativo, ligero, sin librerías de terceros y  seguro.

¿Por qué prescindir de plugins comerciales de búsqueda?

La mayoría de los plugins de búsqueda del repositorio introducen tablas adicionales en la base de datos, hojas de estilo CSS pesadas, scripts JavaScript que ralentizan el renderizado («pintado») y paneles llenos de características comerciales inútiles.

El núcleo de WordPress ya cuenta con un motor de búsqueda integrado extremadamente eficiente que se invoca mediante el parámetro estándar ?s=. Lo único que necesitamos es una interfaz limpia para capturar la búsqueda.

Los requisitos para este plugin han sido:

  • Cero dependencias externas: Sin librerías JS, sin llamadas a CDNs y con icono de lupa insertado en SVG nativo
  • Seguridad estricta: Validación y sanitización de todas las entradas del usuario y escape en el renderizado HTML (prevención de vulnerabilidades XSS
  • Aislamiento de estilos: Generación de selectores dinámicos para evitar conflictos con el tema activo de WordPress
  • Panel de administración e instrucciones: Un panel sencillo dentro de Ajustes > Buscador Indaga para cambiar colores, texto del placeholder y elegir varios diseños de maquetación
  • Atributos en Shortcode: Posibilidad de anular la configuración global directamente desde la etiqueta indaga_search (entre corchetes, []). Aquí podéis verlo insertado funcionamiento.

Estructura y código completo del plugin

Para instalarlo, basta con crear la carpeta /wp-content/plugins/indaga-search-plugin/ y guardar dentro el archivo indaga-search-plugin.php con el siguiente código PHP:

<?php
/**
 * Plugin Name: Indaga Search Form
 * Plugin URI:  https://indaga.net
 * Description: Buscador nativo de WordPress ligero, seguro y sin dependencias. Permite varios diseños y configuraciones personalizables desde el panel o mediante atributos de shortcode.
 * Version:     1.0.0
 * Author:      INDAGA.net
 * Text Domain: indaga-search
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit; // Evitar acceso directo por URL
}

/**
 * 1. REGISTRO DE OPCIONES Y PÁGINA DE CONFIGURACIÓN EN EL PANEL
 */
function isf_register_settings() {
	register_setting( 'isf_options_group', 'isf_placeholder', array(
		'type'              => 'string',
		'default'           => 'Buscar en el sitio...',
		'sanitize_callback' => 'sanitize_text_field',
	) );

	register_setting( 'isf_options_group', 'isf_button_text', array(
		'type'              => 'string',
		'default'           => 'Buscar',
		'sanitize_callback' => 'sanitize_text_field',
	) );

	register_setting( 'isf_options_group', 'isf_layout', array(
		'type'              => 'string',
		'default'           => 'inline',
		'sanitize_callback' => 'isf_sanitize_layout',
	) );

	register_setting( 'isf_options_group', 'isf_primary_color', array(
		'type'              => 'string',
		'default'           => '#2563eb',
		'sanitize_callback' => 'sanitize_hex_color',
	) );

	register_setting( 'isf_options_group', 'isf_bg_color', array(
		'type'              => 'string',
		'default'           => '#ffffff',
		'sanitize_callback' => 'sanitize_hex_color',
	) );

	register_setting( 'isf_options_group', 'isf_border_color', array(
		'type'              => 'string',
		'default'           => '#cbd5e1',
		'sanitize_callback' => 'sanitize_hex_color',
	) );
}
add_action( 'admin_init', 'isf_register_settings' );

function isf_sanitize_layout( $input ) {
	$valid_layouts = array( 'inline', 'stacked', 'minimal', 'icon_only' );
	return in_array( $input, $valid_layouts, true ) ? $input : 'inline';
}

function isf_add_admin_menu() {
	add_options_page(
		'Configuración de Buscador Indaga',
		'Buscador Indaga',
		'manage_options',
		'indaga-search',
		'isf_render_admin_page'
	);
}
add_action( 'admin_menu', 'isf_add_admin_menu' );

function isf_render_admin_page() {
	if ( ! current_user_can( 'manage_options' ) ) {
		return;
	}

	$placeholder   = get_option( 'isf_placeholder', 'Buscar en el sitio...' );
	$button_text   = get_option( 'isf_button_text', 'Buscar' );
	$layout        = get_option( 'isf_layout', 'inline' );
	$primary_color = get_option( 'isf_primary_color', '#2563eb' );
	$bg_color      = get_option( 'isf_bg_color', '#ffffff' );
	$border_color  = get_option( 'isf_border_color', '#cbd5e1' );
	?>
	<div class="wrap">
		<h1>Ajustes del Buscador Indaga.net</h1>
		<p>Personaliza el aspecto del buscador nativo o consulta las instrucciones para incrustarlo mediante shortcode.</p>

		<div style="display: flex; gap: 30px; flex-wrap: wrap; margin-top: 20px;">
			<div style="flex: 1; min-width: 320px; max-width: 600px;">
				<form method="post" action="options.php">
					<?php
					settings_fields( 'isf_options_group' );
					do_settings_sections( 'isf_options_group' );
					?>
					<table class="form-table" role="presentation">
						<tr>
							<th scope="row"><label for="isf_placeholder">Texto del placeholder</label></th>
							<td>
								<input type="text" id="isf_placeholder" name="isf_placeholder" value="<?php echo esc_attr( $placeholder ); ?>" class="regular-text" />
							</td>
						</tr>
						<tr>
							<th scope="row"><label for="isf_button_text">Texto del botón</label></th>
							<td>
								<input type="text" id="isf_button_text" name="isf_button_text" value="<?php echo esc_attr( $button_text ); ?>" class="regular-text" />
							</td>
						</tr>
						<tr>
							<th scope="row"><label for="isf_layout">Diseño de la estructura</label></th>
							<td>
								<select id="isf_layout" name="isf_layout">
									<option value="inline" <?php selected( $layout, 'inline' ); ?>>En línea (Clásico - Botón a la derecha)</option>
									<option value="stacked" <?php selected( $layout, 'stacked' ); ?>>Apilado (Campo de texto arriba, botón abajo)</option>
									<option value="minimal" <?php selected( $layout, 'minimal' ); ?>>Minimalista (Caja integrada con lupa)</option>
									<option value="icon_only" <?php selected( $layout, 'icon_only' ); ?>>Solo icono (Botón cuadrado compacto)</option>
								</select>
							</td>
						</tr>
						<tr>
							<th scope="row"><label for="isf_primary_color">Color principal</label></th>
							<td>
								<input type="color" id="isf_primary_color" name="isf_primary_color" value="<?php echo esc_attr( $primary_color ); ?>" />
							</td>
						</tr>
						<tr>
							<th scope="row"><label for="isf_bg_color">Color de fondo del campo</label></th>
							<td>
								<input type="color" id="isf_bg_color" name="isf_bg_color" value="<?php echo esc_attr( $bg_color ); ?>" />
							</td>
						</tr>
						<tr>
							<th scope="row"><label for="isf_border_color">Color del borde</label></th>
							<td>
								<input type="color" id="isf_border_color" name="isf_border_color" value="<?php echo esc_attr( $border_color ); ?>" />
							</td>
						</tr>
					</table>
					<?php submit_button( 'Guardar cambios' ); ?>
				</form>
			</div>

			<div style="flex: 1; min-width: 300px; max-width: 450px; background: #fff; padding: 20px; border: 1px solid #c3c4c7; border-radius: 4px; height: fit-content;">
				<h2>Instrucciones de Uso</h2>
				<p>Inserta el buscador en cualquier lugar utilizando:</p>
				<p><code>
</code></p> <hr style="margin: 15px 0; border: 0; border-top: 1px solid #eee;" /> <h3>Parámetros del Shortcode</h3> <ul> <li><code>layout="inline|stacked|minimal|icon_only"</code></li> <li><code>placeholder="Buscar..."</code></li> <li><code>color="#2563eb"</code></li> </ul> </div> </div> </div> <?php } /** * 2. LÓGICA DEL SHORTCODE
*/ function isf_search_shortcode( $atts ) { $db_placeholder = get_option( 'isf_placeholder', 'Buscar en el sitio...' ); $db_button_text = get_option( 'isf_button_text', 'Buscar' ); $db_layout = get_option( 'isf_layout', 'inline' ); $db_primary_color = get_option( 'isf_primary_color', '#2563eb' ); $db_bg_color = get_option( 'isf_bg_color', '#ffffff' ); $db_border_color = get_option( 'isf_border_color', '#cbd5e1' ); $atts = shortcode_atts( array( 'placeholder' => $db_placeholder, 'button_text' => $db_button_text, 'layout' => $db_layout, 'color' => $db_primary_color, 'bg' => $db_bg_color, 'border' => $db_border_color, ), $atts, 'indaga_search' ); $layout = isf_sanitize_layout( $atts['layout'] ); $placeholder = sanitize_text_field( $atts['placeholder'] ); $button_text = sanitize_text_field( $atts['button_text'] ); $primary_color = sanitize_hex_color( $atts['color'] ); $bg_color = sanitize_hex_color( $atts['bg'] ); $border_color = sanitize_hex_color( $atts['border'] ); $unique_id = 'indaga-search-' . wp_rand( 1000, 9999 ); $search_svg = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle;"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>'; $output = '<style>'; $output .= '.' . $unique_id . ' { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; width: 100%; margin: 10px 0; }'; $output .= '.' . $unique_id . ' form { display: flex; gap: 8px; margin: 0; width: 100%; box-sizing: border-box; }'; $output .= '.' . $unique_id . ' input[type="search"] { flex: 1; background-color: ' . esc_attr( $bg_color ) . '; border: 1px solid ' . esc_attr( $border_color ) . '; border-radius: 4px; padding: 8px 12px; font-size: 14px; color: #1e293b; outline: none; transition: border-color 0.2s ease; box-sizing: border-box; }'; $output .= '.' . $unique_id . ' input[type="search"]:focus { border-color: ' . esc_attr( $primary_color ) . '; }'; $output .= '.' . $unique_id . ' button[type="submit"] { background-color: ' . esc_attr( $primary_color ) . '; color: #ffffff; border: none; border-radius: 4px; padding: 8px 16px; font-size: 14px; font-weight: 500; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: 6px; }'; if ( 'stacked' === $layout ) { $output .= '.' . $unique_id . ' form { flex-direction: column; }'; $output .= '.' . $unique_id . ' button[type="submit"] { width: 100%; }'; } elseif ( 'minimal' === $layout ) { $output .= '.' . $unique_id . ' form { position: relative; }'; $output .= '.' . $unique_id . ' input[type="search"] { padding-right: 40px; }'; $output .= '.' . $unique_id . ' button[type="submit"] { position: absolute; right: 4px; top: 4px; bottom: 4px; background: transparent; color: ' . esc_attr( $primary_color ) . '; }'; } elseif ( 'icon_only' === $layout ) { $output .= '.' . $unique_id . ' button[type="submit"] { padding: 8px 12px; }'; } $output .= '</style>'; $home_url = esc_url( home_url( '/' ) ); $current_query = get_search_query(); $output .= '<div class="indaga-search-container ' . esc_attr( $unique_id ) . '">'; $output .= '<form role="search" method="get" action="' . $home_url . '">'; $output .= '<input type="search" value="' . esc_attr( $current_query ) . '" name="s" placeholder="' . esc_attr( $placeholder ) . '" aria-label="Buscar" required />'; $output .= '<button type="submit">'; if ( 'minimal' === $layout || 'icon_only' === $layout ) { $output .= $search_svg; } else { $output .= $search_svg . ' ' . esc_html( $button_text ); } $output .= '</button>'; $output .= '</form>'; $output .= '</div>'; return $output; } add_shortcode( 'indaga_search', 'isf_search_shortcode' );

Formas de invocación en tus páginas

Una vez activado el plugin en WordPress, puedes invocar el buscador en cualquier parte de varias formas distintas )lo encontrarás en Ajustes -> Buscador Indaga):

  • Buscador clásico en línea:

    indaga_search

    (recordad, siempre los shortcodes siempre entre corchetes):

  • Formato minimalista con la lupa integrada:
    indaga_search layout="minimal" placeholder="Explorar en Indaga.net..."

  • Formato apilado para barras laterales o pie de página:

    indaga_search layout="stacked" button_text="Buscar en el archivo"

Con este pequeño plugin vuelvo a tener más control sobre el rendimiento del sitio, asegurándome de que el buscador responda al instante, es seguro y no añade sobrecarga al servidor.

Descargar plugin de mi cuenta de pCloud

PD. Actualizaré la página Control de plugins. En breve…

Hooks y actions de WordPress explicados con un plugin real: Mis Plugins

Valencia, 21/09/2026, G.B.
Ya he hablado varias veces por aquí de hooks, actions y filters en WordPress, pero siempre viene bien un ejemplo real y completo en vez de fragmentos sueltos. Así que voy a usar mi propio plugin Mis Plugins como caso práctico: es pequeño, no tiene dependencias externas, y toca casi todos los hooks que se usan en el día a día de un plugin.

Qué es un hook

Un hook es un punto del código de WordPress donde puedes «engancharte» para ejecutar tu propia función, sin tocar el núcleo. Hay dos tipos:

  • Actions: ejecutan tu función en un momento concreto (por ejemplo, cuando se activa un plugin, o cuando se carga el panel de administración). No devuelven nada, solo hacen algo.
  • Filters: reciben un valor, tu función lo modifica y lo devuelve. Se usan para transformar datos antes de que WordPress los use.

Mis Plugins usa sobre todo actions, así que empiezo por ahí.

register_activation_hook: al activar el plugin

Este hook se dispara una sola vez, justo cuando activas el plugin desde el panel. Lo uso para crear la tabla propia en la base de datos:

register_activation_hook( __FILE__, 'indaga_mp_activar' );

function indaga_mp_activar() {
    global $wpdb;
    $table_name = indaga_mp_table_name();
    $charset_collate = $wpdb->get_charset_collate();

    require_once ABSPATH . 'wp-admin/includes/upgrade.php';

    $sql = "CREATE TABLE {$table_name} (
        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
        fecha DATE NOT NULL,
        nombre_plugin VARCHAR(191) NOT NULL,
        PRIMARY KEY (id)
    ) {$charset_collate};";

    dbDelta( $sql );
}

La función dbDelta es la forma segura de crear o actualizar tablas propias: compara la estructura que le pasas con la que ya existe y hace los cambios necesarios sin borrar datos.

plugins_loaded: comprobar la versión en cada carga

register_activation_hook solo se dispara al activar, así que si subo una versión nueva del plugin con columnas añadidas, necesito otro punto de enganche que se ejecute siempre. Para eso uso plugins_loaded, comparando un número de versión guardado en las opciones:

add_action( 'plugins_loaded', 'indaga_mp_comprobar_version_bd' );

function indaga_mp_comprobar_version_bd() {
    if ( get_option( 'indaga_mp_db_version' ) !== INDAGA_MP_DB_VERSION ) {
        indaga_mp_activar();
    }
}

Así, cuando subí la versión 2.0.0 con los campos de autor y tags, la tabla se actualizó sola en cuanto WordPress cargó los plugins, sin que el usuario tuviera que desactivar y reactivar nada.

admin_menu: añadir la página al panel

Este hook se dispara mientras WordPress construye el menú de administración. Es el sitio correcto para registrar una nueva pantalla:

add_action( 'admin_menu', 'indaga_mp_menu_admin' );

function indaga_mp_menu_admin() {
    add_menu_page(
        'Mis Plugins',
        'Mis Plugins',
        'manage_options',
        INDAGA_MP_SLUG,
        'indaga_mp_render_pagina_admin',
        'dashicons-admin-plugins',
        80
    );
}

admin_init: procesar el formulario, nunca en el callback del menú

Este es el hook con el que más cuidado tengo, porque un error aquí ya me ha dado algún quebradero de cabeza en otros plugins: procesar un formulario POST dentro de la función que pinta la página de administración puede dejar la pantalla congelada. La solución es mover ese procesamiento a admin_init, comprobando primero en qué pantalla estamos:

add_action( 'admin_init', 'indaga_mp_procesar_admin_init' );

function indaga_mp_procesar_admin_init() {
    if ( ! isset( $_GET['page'] ) || INDAGA_MP_SLUG !== $_GET['page'] ) {
        return;
    }
    if ( ! current_user_can( 'manage_options' ) ) {
        return;
    }
    // Aquí se valida el nonce y se guarda o borra el registro.
}

admin_enqueue_scripts: cargar estilos solo donde tocan

Para no meter CSS en todas las pantallas del panel, engancho los estilos a este hook y compruebo en qué página estoy antes de cargarlos:

add_action( 'admin_enqueue_scripts', 'indaga_mp_admin_estilos' );

function indaga_mp_admin_estilos( $hook ) {
    if ( strpos( (string) $hook, INDAGA_MP_SLUG ) === false ) {
        return;
    }
    wp_register_style( 'indaga-mp-admin', false );
    wp_enqueue_style( 'indaga-mp-admin' );
    wp_add_inline_style( 'indaga-mp-admin', '/* CSS aquí */' );
}

add_shortcode: mostrarlo en el front end

No es técnicamente un action ni un filter, pero funciona con la misma lógica de enganche: le dices a WordPress que, cuando encuentre el shortcode mis_plugins (entre brackets, que si no se ejecuta aquí)  en el contenido, ejecute tu función y sustituya el shortcode por lo que devuelva:

add_shortcode( 'mis_plugins', 'indaga_mp_shortcode' );

function indaga_mp_shortcode( $atts ) {
    $atts = shortcode_atts( array(
        'por_pagina' => 10,
    ), $atts, 'mis_plugins' );

    // Consulta a la base de datos y devuelve el HTML con ob_start()/ob_get_clean().
}

Un detalle interesante para quien quiera profundizar en filters: shortcode_atts, por dentro, aplica el filter shortcode_atts_mis_plugins, que permite a otro plugin modificar los atributos por defecto sin tocar mi código. Es un buen ejemplo de cómo WordPress deja puertas abiertas incluso en funciones tan pequeñas como esta.

Resumen

Con seis hooks (uno de activación, uno de carga, tres de administración y un shortcode) queda montado un plugin completo, con su propia tabla, su formulario y su vista en el front end. Si quieres verlo funcionando, tienes el listado en Control de plugins, con el catálogo de todos los que he ido publicando aquí.

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

Porqué 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

Un plugin propio para aprender comandos básicos de Linux en WordPress

plugin comandos linux

Tal y como comentaba aquí, he preparado un pequeño plugin para mostrar comandos de Linux, para aprender, vaya. Insertaré el shortcode en una nueva página, en vez de en una entrada o post

Valencia, 06/09/2026, G.B.
Llevo tiempo trabajando en plugins a medida, sin dependencias externas, para cubrir necesidades muy concretas sin cargar el sitio con librerías de terceros. El último que he montado va dirigido a quien empieza con Linux: Comandos Linux, un plugin que permite gestionar una pequeña base de datos de comandos —con su explicación y un ejemplo práctico— y mostrarla en el front end mediante un shortcode.

Qué hace

El plugin crea su propia tabla en la base de datos con cuatro campos por registro:

  • Nombre del comando (ls, grep, chmod…)
  • Explicación, en lenguaje sencillo
  • Ejemplo práctico, listo para copiar y probar en terminal

Desde el panel de administración se pueden crear, editar y eliminar comandos con un formulario simple, y el listado incluye un buscador y paginación para cuando la lista crezca.

El shortcode

Para mostrarlo en cualquier entrada o página basta con insertar, entre corchetes []:

comandos_linux

El front end reproduce la misma lógica que el panel: buscador propio y resultados paginados, sin necesidad de recargar toda la página gracias a los parámetros por URL. Si se quiere ajustar cuántos comandos se muestran por página, se puede indicar con (entre corchetes []:

comandos_linux por_pagina="10"

Porqué así

Como en el resto de plugins que he ido publicando aquí, la idea es mantenerlo ligero: sin librerías externas, con consultas preparadas contra la base de datos, y siguiendo el patrón POST/Redirect/GET para evitar reenvíos accidentales de formularios. Es la misma filosofía que ya apliqué en el plugin de recursos o en el de autores: cubrir una necesidad concreta con el menor peso posible.

De momento arranca con comandos básicos como ls, cd, grep o chmod, pero la idea es irlo ampliando poco a poco a medida que vaya cubriendo más terreno, desde gestión de permisos hasta redes o procesos.

Así queda insertado el shortcode en una página  (iré añadiendo más y mejorándolo): Comandos Linux


Descargar el plugin desde mi cuenta de pCloud

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
}

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.