Cómo crear un bloque personalizado de Gutenberg
Crea un bloque Gutenberg personalizado con React, block.json y PHP. Gestiona atributos, renderizado estático o dinámico y versiones obsoletas para evitar errores.
Un bloque de Gutenberg es un componente de React que WordPress registra con un nombre dentro de un espacio de nombres (namespace). Se renderiza en el editor mediante una función edit y se muestra en el frontend mediante una función save o una plantilla de renderizado en PHP.
Si sabes escribir un componente de React, sabes escribir un bloque. La mayor parte de la dificultad está en las piezas específicas de WordPress que rodean al componente: un manifiesto JSON, una llamada de registro en PHP y un paso de validación que muestra el aviso «This block contains unexpected or invalid content» («Este bloque contiene contenido inesperado o no válido») en cuanto cambias el marcado.
Antes de que existieran los bloques, el comportamiento personalizado se añadía incorporando JavaScript personalizado a los temas de WordPress con wp_enqueue_script. En lo que respecta al contenido, los bloques sustituyen ese enfoque: los editores disponen de un componente visual y editable en lugar de un script que se ejecuta sobre HTML estático. Esta guía construye un bloque de aviso (callout) con un cuerpo RichText editable y un campo de URL en la barra lateral. Explica cada archivo generado y muestra por qué se producen los errores de validación y cómo solucionarlos.
Puntos clave
npx @wordpress/create-block@latest callout --namespace=openreplaygenera un plugin de bloque completo, y@wordpress/scriptslo compila connpm startynpm run build.- block.json declara el bloque. Las llamadas de registro de PHP y de JavaScript leen de ese único archivo el nombre, los atributos, los supports y las rutas de los recursos.
- Los atributos sin
sourcese almacenan como JSON en el comentario delimitador HTML del bloque. Los atributos consourceyselectorse extraen de nuevo del marcado guardado. - Al cargar una entrada, el editor vuelve a ejecutar
save()y compara el resultado con el marcado almacenado. Cualquier diferencia provoca el error de contenido no válido hasta que un elemento dedeprecatedreproduzca la salida anterior. - Un bloque dinámico devuelve
nulldesdesavey se renderiza en PHP en cada petición. Úsalo para salidas que deban cambiar sin que nadie vuelva a guardar la entrada.
Un bloque es un componente de React
Un bloque de Gutenberg es un componente de React cuya API específica de WordPress se corresponde con patrones que los desarrolladores de React ya conocen:
| Concepto de React | Equivalente en un bloque |
|---|---|
| JSX del componente | Función edit |
| Props | attributes |
setState | setAttributes |
| Salida renderizada en el servidor | Función save o render.php |
Un bloque de Gutenberg se diferencia de un componente de React convencional en dos aspectos. Primero, la salida de save se escribe como HTML en el contenido de la entrada. Segundo, el esquema de atributos se define en JSON en lugar de en PropTypes o TypeScript.
Genera la estructura del bloque con create-block
El paquete oficial @wordpress/create-block genera un plugin de bloque funcional con las herramientas de compilación ya configuradas. Ejecútalo dentro de wp-content/plugins:
npx @wordpress/create-block@latest callout --namespace=openreplay
cd callout
npm start # watch mode during development
npm run build # production build
Si omites --namespace, el bloque se llamará create-block/callout. Con npm start en ejecución, activa el plugin en la pantalla de administración.
| Archivo | Propósito |
|---|---|
callout.php | Cabecera del plugin y registro del bloque en init |
package.json | Scripts de npm que envuelven @wordpress/scripts |
src/callout/block.json | Declaración del bloque: nombre, atributos, supports y recursos |
src/callout/index.js | Llama a registerBlockType con edit y save |
src/callout/edit.js | Componente de la interfaz del editor |
src/callout/save.js | Marcado que se almacena en el contenido de la entrada |
src/callout/style.scss | Estilos para el frontend y el editor |
src/callout/editor.scss | Estilos solo para el editor |
build/ | Salida compilada que WordPress carga realmente |
build/blocks-manifest.php | Metadatos de todos los bloques, generados durante la compilación |
El archivo PHP es un plugin normal (consulta cómo escribir un plugin de WordPress desde cero) al que se ha añadido una llamada de registro. El concepto subyacente es register_block_type(), que acepta una carpeta que contiene block.json. La estructura generada lo envuelve en una llamada de registro por lotes que requiere WordPress 6.8 o posterior, y la cabecera del plugin generado establece Requires at least: 6.8 en consecuencia. Una versión simplificada de esa llamada:
add_action( 'init', function () {
wp_register_block_types_from_metadata_collection(
__DIR__ . '/build',
__DIR__ . '/build/blocks-manifest.php'
);
} );
La guía de registro de bloques explica ambas formas.
block.json: dónde se declara el bloque
El archivo block.json es la declaración única de un bloque. Las llamadas de registro de PHP y de JavaScript leen de él el nombre, los atributos, los supports y las rutas de los recursos, de modo que un solo archivo describe el bloque tanto para el editor como para el servidor.
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "openreplay/callout",
"version": "0.1.0",
"title": "Callout",
"category": "text",
"icon": "info",
"attributes": {
"content": { "type": "string", "source": "html", "selector": "p" },
"url": { "type": "string", "default": "" }
},
"supports": { "html": false },
"textdomain": "callout",
"editorScript": "file:./index.js",
"editorStyle": "file:./index.css",
"style": "file:./style-index.css"
}
name tiene el formato namespace/slug. Considéralo permanente, porque se escribe en el marcado de cada entrada que utiliza el bloque.
apiVersion: 3 llegó con WordPress 6.3 y declara que el bloque funciona dentro del editor en iframe. Desde WordPress 6.9, el esquema solo acepta la versión 3, y los bloques registrados con una versión anterior muestran una advertencia en la consola cuando SCRIPT_DEBUG está activado. A partir de WordPress 7.1, el editor de entradas siempre se ejecuta en un iframe, independientemente del apiVersion que declare el bloque, por lo que todos los bloques deben funcionar dentro de él.
attributes define los datos del bloque. type y default funcionan como cabría esperar. source controla dónde reside el valor. La referencia de atributos explica que un valor sin source acaba en el comentario delimitador, mientras que source: "html" lee el HTML interno del elemento coincidente, que es en lo que suelen basarse los campos RichText.
| Definición del atributo | Dónde reside el valor |
|---|---|
Sin source (url) | JSON dentro del comentario <!-- wp:... --> |
source + selector (content) | Se extrae del HTML guardado en cada carga |
Este es el aspecto del contenido almacenado de la entrada (a modo ilustrativo):
<!-- wp:openreplay/callout {"url":"https://example.com"} -->
<div class="wp-block-openreplay-callout"><p>Deploys freeze Friday at noon.</p></div>
<!-- /wp:openreplay/callout -->
supports activa funciones del editor. html: false oculta la opción «Editar como HTML». Claves como align y color añaden controles a la barra de herramientas y a la barra lateral sin código adicional, tal como se detalla en la referencia de supports.
Las rutas file: se resuelven en relación con el block.json que se registra, es decir, la copia de build/callout/, situada junto a los archivos compilados index.js, index.css y style-index.css. Por eso PHP apunta a build y no a src. La referencia de metadatos del bloque documenta todas las claves.
edit.js: RichText, InspectorControls y useBlockProps
El componente edit solo se renderiza dentro del editor. Recibe attributes y setAttributes como props, y setAttributes cumple la misma función que un setter de estado: fusiona el objeto parcial que le pasas y desencadena un nuevo renderizado.
import { __ } from '@wordpress/i18n';
import { useBlockProps, RichText, InspectorControls } from '@wordpress/block-editor';
import { PanelBody, TextControl } from '@wordpress/components';
export default function Edit( { attributes, setAttributes } ) {
const { content, url } = attributes;
return (
<>
<InspectorControls>
<PanelBody title={ __( 'Link', 'callout' ) }>
<TextControl
label={ __( 'URL', 'callout' ) }
value={ url }
onChange={ ( value ) => setAttributes( { url: value } ) }
/>
</PanelBody>
</InspectorControls>
<div { ...useBlockProps() }>
<RichText
tagName="p"
value={ content }
onChange={ ( value ) => setAttributes( { content: value } ) }
placeholder={ __( 'Write the callout…', 'callout' ) }
/>
</div>
</>
);
}
useBlockProps() añade los nombres de clase y los atributos data-* que el editor necesita para la selección y los estilos, así que aplícalo con el operador spread sobre el elemento más externo. RichText permite a los usuarios editar texto dentro del propio bloque. InspectorControls traslada a la barra lateral los ajustes que no se editan en línea, como una URL. PanelBody agrupa esos controles en una sección plegable.
save.js y el error de validación del bloque
La función save genera el marcado que se almacena en el contenido de la entrada. Debe ser una función pura de los atributos: sin estado, sin efectos y sin hooks, salvo useBlockProps.save().
import { useBlockProps, RichText } from '@wordpress/block-editor';
export default function save( { attributes } ) {
return (
<div { ...useBlockProps.save() }>
<RichText.Content tagName="p" value={ attributes.content } />
</div>
);
}
RichText.Content genera el valor almacenado como marcado simple, y useBlockProps.save() añade la clase del contenedor en el frontend.
¿Por qué falla la validación del bloque?
Cuando se abre una entrada en el editor, WordPress ejecuta la función save actual del bloque con los atributos analizados y compara el resultado con el marcado almacenado en la entrada. La guía de Edit y Save considera no válido cualquier bloque en el que exista una diferencia entre esa salida recién generada y el HTML almacenado. El editor muestra entonces «Este bloque contiene contenido inesperado o no válido» y registra un diff en la consola del navegador.
Cambiar una función save no reescribe las entradas que ya contienen el bloque. Supongamos que la v2 añade una clase y un enlace:
export default function save( { attributes } ) {
const { content, url } = attributes;
return (
<div { ...useBlockProps.save() }>
<RichText.Content tagName="p" className="callout__body" value={ content } />
{ url && <a href={ url }>Learn more</a> }
</div>
);
}
Ahora todos los bloques callout existentes fallan la validación, porque la nueva salida de save ya no coincide con el marcado almacenado anteriormente.
La solución: deprecated
Un elemento de deprecated soluciona el error de validación del bloque conservando los atributos y la función save anteriores. También puede incluir una función migrate opcional que transforme los atributos antiguos a una nueva estructura. Con este elemento definido, el editor puede reconocer el marcado antiguo y actualizarlo la próxima vez que se guarde la entrada.
import { registerBlockType } from '@wordpress/blocks';
import { useBlockProps, RichText } from '@wordpress/block-editor';
import Edit from './edit';
import save from './save';
import metadata from './block.json';
const v1 = {
attributes: {
content: { type: 'string', source: 'html', selector: 'p' },
url: { type: 'string', default: '' },
},
save( { attributes } ) {
return (
<div { ...useBlockProps.save() }>
<RichText.Content tagName="p" value={ attributes.content } />
</div>
);
},
};
registerBlockType( metadata.name, { edit: Edit, save, deprecated: [ v1 ] } );
Cada elemento incluye sus propios attributes porque el análisis del marcado depende de ellos. Si la v2 también hubiera cambiado el selector de p a .callout__body, v1 seguiría necesitando selector: 'p' para leer correctamente las entradas antiguas.
La referencia de obsolescencia (deprecation) establece estas reglas:
- Ordena los elementos del más reciente al más antiguo.
- El editor prueba cada elemento por separado contra el marcado tal como se guardó. Un elemento nunca transmite su resultado al siguiente.
- Si la salida de
savede un elemento no coincide con el HTML almacenado, el editor ignora ese elemento y sumigratenunca se ejecuta. - Una función
isEligibleopcional puede hacer que un elemento se aplique a un bloque que ya supera la validación.
Tres hábitos evitan la mayoría de los errores de validación:
- No incluyas nunca valores no deterministas en
save, como fechas o IDs aleatorios. - No leas nunca estado ni contexto en
save. - Trata la salida de
savecomo un esquema de base de datos: cada cambio necesita una migración.
¿Qué es un bloque dinámico?
Un bloque dinámico devuelve null desde save, almacena solo sus atributos en el contenido de la entrada y se renderiza en PHP en cada petición. Elige este tipo de bloque cuando la salida deba cambiar sin que nadie vuelva a guardar la entrada, como en una lista de las últimas entradas publicadas.
| Bloque estático | Bloque dinámico | |
|---|---|---|
| El marcado reside en | El contenido de la entrada | Una plantilla PHP |
| Se actualiza cuando | Se vuelve a guardar la entrada | En cada petición |
save devuelve | JSX | null |
| Coste en el servidor | Ninguno, más allá de servir el contenido | Ejecuta PHP en cada renderizado |
Genera uno con npx @wordpress/create-block@latest callout --namespace=openreplay --variant dynamic, o añade tú mismo "render": "file:./render.php" a block.json. La siguiente plantilla lista entradas de un tipo de contenido personalizado. Presupone que has añadido un atributo count de tipo number:
<?php
$query = new WP_Query( array(
'post_type' => 'project',
'posts_per_page' => isset( $attributes['count'] ) ? (int) $attributes['count'] : 3,
) );
?>
<ul <?php echo get_block_wrapper_attributes(); ?>>
<?php while ( $query->have_posts() ) : $query->the_post(); ?>
<li><a href="<?php echo esc_url( get_permalink() ); ?>"><?php echo esc_html( get_the_title() ); ?></a></li>
<?php endwhile; wp_reset_postdata(); ?>
</ul>
$attributes contiene los atributos analizados del bloque. get_block_wrapper_attributes() es el equivalente en PHP de useBlockProps. Si prefieres una función en lugar de un archivo de plantilla, pasa un render_callback a register_block_type(). Los bloques dinámicos nunca producen errores de validación, porque el editor no tiene marcado almacenado con el que comparar.
Conclusión
Un bloque es un componente de React más una declaración JSON y un contrato de marcado almacenado. Para un desarrollador de React, el componente es la parte fácil. El contrato es donde los bloques se rompen: en cuanto haya entradas reales que contengan tu bloque, cada cambio en save necesitará su correspondiente elemento en deprecated. Genera el bloque callout, añade una clase a la salida de su save y observa cómo aparece el error en la consola. Después, escribe la deprecación que lo resuelve. Una vez que hayas visto ese ciclo, el error de contenido no válido dejará de ser un misterio.
Preguntas frecuentes
¿Un bloque personalizado de Gutenberg debe estar en un plugin o en un tema?
En un plugin. El marcado del bloque se guarda en el contenido de la entrada, así que el bloque debe seguir registrado mientras haya entradas que lo utilicen. Si se desactiva el código que lo registra, por ejemplo al cambiar de tema, el editor muestra un mensaje indicando que el sitio no incluye compatibilidad con ese bloque y ofrece dejarlo intacto o eliminarlo. Un plugin mantiene el bloque disponible aunque se cambie de tema.
¿Qué hace «Intentar recuperar el bloque» con un bloque no válido?
«Intentar recuperar el bloque» (Attempt Block Recovery) reconstruye el bloque a partir de sus atributos analizados con la función save actual y sustituye el marcado almacenado. Corrige un único bloque en una única entrada y descarta cualquier contenido que los atributos no recojan. Sortea el problema en el editor, pero no corrige el código. Un elemento de deprecated permite que todas las entradas existentes se actualicen sin que nadie tenga que recuperar bloques a mano.
¿Cómo añado un segundo bloque al mismo plugin?
Crea una nueva carpeta dentro de src con sus propios block.json, index.js, edit.js y save.js. Los comandos start y build de @wordpress/scripts recorren src y sus subcarpetas en busca de archivos block.json, compilan cada uno como un punto de entrada independiente en la carpeta correspondiente dentro de build y escriben todos los bloques en el manifiesto de bloques que registra el plugin. Reinicia npm start después de añadir la carpeta.
¿Puede un bloque dinámico contener bloques anidados?
Sí, pero save no puede devolver null. Renderiza el área de bloques hijos con InnerBlocks o useInnerBlocksProps en edit, y devuelve InnerBlocks.Content desde save para que el marcado de los hijos se almacene en el contenido de la entrada. La plantilla PHP recibe ese marcado ya renderizado en la variable $content, junto a $attributes. Cada bloque admite una única área de InnerBlocks, y allowedBlocks limita qué bloques hijos pueden insertar los editores.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k