💬 Proponiendo un nuevo enfoque para 'Gutenberg y aplicaciones desacopladas'
Hace unos días, Jason Bahl, creador de WPGraphQL, publicó Gutenberg and Decoupled Applications, analizando los beneficios y limitaciones de 3 enfoques para integrar GraphQL con Gutenberg.
Una semana antes, también había dicho en Twitter que el enfoque de Gato GraphQL para modelar Gutenberg es inapropiado:
Esto no es algo de lo que presumir, en mi opinión. Una cosa que GraphQL intenta resolver con un Schema Tipado es proporcionar previsibilidad y consistencia para los clientes, y darles el control para pedir lo que quieren, hasta el nivel de campo.
Devolver un tipo "Object" wildcard sin forma predecible significa que las aplicaciones cliente pueden romperse en cualquier momento porque ya no hay un contrato entre el servidor y el cliente. El servidor le ha quitado control al cliente.
A través de este artículo, me sumo a la conversación. Abordaré la crítica de Jason y, al hacerlo, describiré el enfoque de mi plugin, y mostraré por qué creo que en realidad puede encajar muy bien con Gutenberg.
Usar COPE para extraer metadatos de Gutenberg
Mi solución podría considerarse el 4º enfoque, y es la siguiente:
Para obtener los datos de Gutenberg que alimenten GraphQL, no crear un esquema adicional en el lado PHP, ni duplicar ningún dato existente. En su lugar, extraer los datos del contenido almacenado de los bloques, usando la estrategia COPE ("Create Once, Publish Everywhere").
(COPE es una estrategia que permite tener una única fuente de verdad de contenido, y exponerla a distintas aplicaciones. En nuestro caso, la única fuente de verdad es los datos de los bloques Gutenberg, tal como se almacenan en la base de datos. He descrito COPE, y su implementación para WordPress, en este artículo.)
Finalmente, podemos usar GraphQL para recuperar los datos extraídos, para cualquier bloque Gutenberg, mapeando todos los bloques a un único tipo Block.
Esta estrategia es un compromiso, no una solución definitiva
Esta estrategia no resuelve el problema que Jason señala: la falta de un esquema en el lado del servidor, que permitiría la creación de un contrato entre el servidor y el cliente.
COPE no puede resolver este problema porque, solo desde el contenido almacenado, no podemos recrear el esquema:
- El contenido almacenado no indica el tipo del campo
- El contenido almacenado no indica qué restricciones tiene el campo (¿es nullable? ¿es un entero positivo? ¿es la cadena para un email o una URL?)
- Los campos nullable pueden tener un valor por defecto, que no estará presente en el contenido almacenado
Sin embargo, usando la estrategia COPE, y un único tipo Block para representar todos los bloques, Gato GraphQL puede construir una integración muy decente con Gutenberg, que supera las limitaciones existentes.
Lo explicaré a lo largo de este artículo.
La integración de Gato GraphQL con Gutenberg
Esta solución es un trabajo en progreso, pero ya puedo explicar cómo se comportará.
En lugar de depender de un tipo distinto por bloque (como hace WPGraphQL al apoyarse en el plugin WPGraphQL for Gutenberg), Gato GraphQL proporcionará un único tipo Block para representar todos los bloques.
En esta consulta, el campo Post.blockDataItems recupera una lista de elementos Block de la entrada (para distintos bloques Gutenberg, incluyendo párrafos, imágenes, listas y otros):
{
post(by: { id: 1499 }) {
title
blockDataItems
}
}Si queremos recuperar datos para un bloque específico, podemos filtrar por el nombre del bloque (core/paragraph, core/quote, etc).
En esta consulta, solo recuperamos los bloques de imagen:
{
post(by: { id: 1177 }) {
title
blockDataItems(
filterBy: { include: "core/image" }
)
}
}Inspeccionando el único tipo Block
Con este enfoque, la respuesta puede variar según el contenido almacenado, no según un esquema. Esta cualidad es tanto su ventaja (ya que hace la API flexible) como su desventaja (no podemos imponer contratos servidor-cliente).
Cada elemento Block contiene dos propiedades:
name: El nombre del bloque (core/paragraph,core/quote, etc)meta: Los metadatos contenidos en el bloque
Cada bloque Gutenberg es distinto, conteniendo datos distintos (un contenido de párrafo, un vídeo de YouTube, una URL de imagen y dimensiones, etc). Por lo tanto, los datos contenidos en la respuesta para el campo meta también serán diferentes.
Como tal, el campo meta ha sido mapeado simplemente como un objeto JSON (que puede contener datos "en bruto"), vía un tipo JSONObject correspondiente en el esquema GraphQL.
Produce esta respuesta:
{
"data": {
"post": {
"title": "COPE with WordPress: Post demo containing plenty of blocks",
"blockDataItems": [
{
"name": "core/paragraph",
"attributes": {
"content": "Lorem ipsum dolor sit amet"
}
},
{
"name": "core/image",
"attributes": {
"src": "https://ps.w.org/gutenberg/assets/banner-1544x500.jpg"
}
},
{
"name": "core/quote",
"attributes": {
"quote": "Etiam tempor orci eu lobortis elementum nibh tellus molestie",
"cite": "Aristoteles"
}
},
{
"name": "core/heading",
"attributes": {
"size": "xl",
"heading": "Welcome to my site"
}
},
{
"name": "core/list",
"attributes": {
"items": [
"First element",
"Second element",
"Third element"
]
}
},
]
}
}
}Como podemos ver, tenemos distintos bloques recuperando distintas propiedades:
core/paragraphtiene la propiedadcontentcore/imagetiene la propiedadsrc, y opcionalmente las propiedadeswidth,heightycaption(no apareciendo en la respuesta de arriba)core/quotetiene las propiedadesquoteycite(para la persona citada)core/headingtiene las propiedadesheaderysize(el valorxlrepresenta<h2>, porque COPE desacopla el valor de la aplicación destino, en este caso un sitio web)core/listtiene la propiedaditems, que es una lista de elementos
Por qué el tipo JSONObject no es parte de la spec
El tipo JSONObject que describí arriba permite a GraphQL recuperar campos "dinámicos" (como campos que desconocemos), o campos que pueden tener múltiples configuraciones (como puede ser el caso con los bloques Gutenberg).
Ahora, la spec de GraphQL actualmente no soporta los tipos JSONObject o Map. Se ha solicitado el soporte, por razones como:
[...] la falta de esta característica es particularmente problemática porque está soportada en muchos de los sistemas de tipos y servicios con los que GraphQL se interconecta.
Esto lleva a implementar resolvers personalizados en el servidor, seguidos de transformaciones personalizadas en el cliente, para lidiar con situaciones donde mi servidor está enviando un Map, y mi cliente quiere un Map, y GraphQL está en el medio sin soporte para Maps. Sí, es posible, y lo he hecho, pero es bastante boilerplate y abstracción que parece anular el propósito de escribir la spec de la API en GraphQL.
Esta característica no está soportada por la spec porque lidiar con campos dinámicos va contra el comportamiento de typing fuerte de GraphQL, que rompe el contrato entre servidor y cliente.
Aun así, este tipo puede ser beneficioso para Gutenberg, como mostraré más adelante.
Problemas al usar un tipo distinto por bloque, y un registro en el lado del servidor
Si creamos un nuevo tipo GraphQL por bloque, entonces todos los plugins deben tener sus bloques añadidos al esquema GraphQL. Esto podría lograrse automáticamente haciendo que todos los bloques definan sus propiedades en el registro propuesto en el lado del servidor.
Si no lo hacen, sus bloques no estarán disponibles para la API, y esto puede tener consecuencias adicionales. En algunas circunstancias, todo el contenido consultado de la entrada puede volverse poco fiable.
Este puede ser el caso cuando GraphQL interactúa con un servicio externo en la nube, que aplica alguna función a todos los bloques de la entrada (piensa en traducción, corrección gramatical, sugerencias SEO, analíticas, etc).
Veamos un ejemplo de esto.
Como las capacidades multilingües se añadirán a Gutenberg en la fase 4, modelemos cómo traducir todos los bloques del plugin, vía una llamada a la API de Google Translate ejecutada mediante una directiva @strTranslate.
(Tras esta traducción inicial basada en API, el usuario puede seguir editando la entrada del blog, en el idioma traducido, siempre dentro del editor de WordPress.)
Distintos bloques contienen distintas piezas de información que deben traducirse:
core/paragraph: el textocore/image: el captioncore/quote: la cita, y la persona citada (ya que podría ser el título de la persona, como "The school headmaster")core/heading: la cabeceracore/list: todos los elementos de la lista
Usando un tipo distinto por bloque, la consulta resultante puede ser algo así:
{
post(by: { id: 1 }) {
blocks {
... on CoreParagraphBlock {
content @strTranslate
}
... on CoreImageBlock {
caption @strTranslate
}
... on CoreQuoteBlock {
quote @strTranslate
cite @strTranslate
}
... on CoreHeadingBlock {
heading @strTranslate
}
... on CoreListBlock {
items @strTranslateList
}
... on EmbedTwitterBlock {
caption @strTranslate
}
... on EmbedYoutubeBlock {
caption @strTranslate
}
... on EmbedVimeoBlock {
caption @strTranslate
}
}
}
}Y así sucesivamente. Cuantos más bloques tengamos, más larga será esta consulta, fácilmente abarcando cien líneas o más.
El problema obvio es que la consulta se convierte en una bestia salvaje que tenemos que mantener.
Además, necesitamos introducir funcionalidad personalizada para que funcione con cada bloque. Por ejemplo, @strTranslate no funciona con CoreListBlock.items, que devuelve una lista de strings (es decir, devuelve [String], mientras que la directiva espera String), por lo que tenemos que crear @strTranslateList.
Y entonces core/table necesitaría su propia directiva personalizada (@strTranslateTable?).
Y bloques personalizados de terceros pueden necesitar sus propias directivas personalizadas.
Y luego, veo un par de problemas más.
Es todo o nada
Una entrada del blog puede contener cualquier bloque instalado en el editor de WordPress. Y no sabemos de antemano (al programar la consulta) qué bloques consume la entrada.
Entonces, con un tipo por bloque, el número de tipos a manejar en la consulta no será equivalente al número de bloques en la entrada. En su lugar, será equivalente al número de bloques instalados en el editor de WordPress.
¿Qué ocurre si tenemos 100 bloques en nuestro sitio, incluyendo tanto del core de WordPress como de plugins? Entonces necesitamos tener 100 tipos mapeados al esquema GraphQL. Uno solo que no esté mapeado puede romper el "contrato de contenido", resultando en que algunos bloques sean traducidos del inglés al francés, mientras que otros permanezcan en inglés.
Como resultado, no podremos confiar más en las entradas traducidas, contengan o no el bloque ofensor. Así que si no todos los bloques se añaden al registro, entonces la aplicación puede volverse poco fiable.
La consulta debe actualizarse cada vez que se instala un nuevo bloque
Asimismo, cada bloque debe manejarse en la consulta GraphQL. Eso significa que, cada vez que instalemos un nuevo bloque, necesitamos ir al código de nuestra aplicación, actualizarlo, y re-desplegarlo.
Esto no es solo burocracia extra: No podremos instalar un bloque en un sitio en producción, sin el temor de romper la aplicación (hasta que todas las consultas se actualicen).
GraphQL debe servir a WordPress, no al revés
Considerando de nuevo por qué JSONObject no se añadió a la spec de GraphQL, es porque no encaja con la forma de hacer las cosas de GraphQL.
Sin embargo, aquí no nos preocupa realmente GraphQL. Solo nos preocupa WordPress y, más específicamente en este caso, Gutenberg.
Al integrar GraphQL con Gutenberg, GraphQL operará dentro del contexto de WordPress. Eso significa que WordPress necesitará satisfacer los requisitos de GraphQL. Pero más importante, es GraphQL quien necesita satisfacer los requisitos de WordPress.
Y en caso de conflicto, WordPress tiene prioridad.
Si una característica no encaja con GraphQL, pero no obstante encaja con Gutenberg, ¿debería considerarse?
Creo que sí.
Veamos cómo un único tipo Block puede servir mejor a Gutenberg.
Resolviendo los problemas anteriores vía un único tipo Block
Siguiendo el ejemplo anterior, traducir todos los bloques de una entrada de inglés a francés, usando un único tipo Block, se hará así (o algo en torno a este concepto):
{
post(by: { id: 1 }) {
blocks {
name
meta
@advancePointersInArray(paths: "{{ translatablePaths }}")
@underEachArrayItem
@strTranslate(from: "en", to: "fr")
}
}
}¿Eso es todo? ¿Toda la consulta? ¿Para traducir todos los bloques? Sí.
¿Funcionará para todos los bloques, tanto del core como de plugins, ya existentes o por crear? Sí.
¿Esta consulta te parece un poco rara? Si es así, es porque usa características no estándar de GraphQL, soportadas solo por Gato GraphQL:
{{ translatablePaths }}es un campo embebible, para introducir el valor de un campo como argumento de otro campo o directiva (en este caso, el tipoBlocktendrá un campotranslatableFields, cuyo valor se inyecta en la directiva@advancePointersInArray)- las directivas pueden componerse con otras directivas
Ahora, si una característica satisface exactamente lo que el CMS necesita, pero la característica es no estándar, ¿deberíamos usarla? Creo que sí.
También he solicitado estas características para la spec de GraphQL (aunque no se aceptarán):
Cómo funciona el único tipo Block
Aviso: sección técnica por delante.
El tipo Block tendrá un campo translatablePaths, devolviendo un array de las propiedades del JSONObject que deben traducirse:
core/paragraphdevuelve["content"]core/imagedevuelve["caption"]core/quotedevuelve["quote", "cite"]core/headingdevuelve["header"]core/listdevuelve["items.0", "items.1", "items.2", ...]
@advancePointersInArray es una meta-directiva: modifica el contexto para una directiva subsiguiente. Hace que la directiva siguiente reciba un sub-elemento de dentro del JSONObject consultado, como la propiedad content del bloque de párrafo. La lista de rutas se obtiene vía el campo translatablePaths, evaluado sobre la misma entidad consultada.
Luego, @underEachArrayItem es otra meta-directiva, que itera sobre una lista de elementos de la entidad consultada, y pasa una referencia al elemento iterado a la siguiente directiva. En este caso, obtiene toda la lista de las propiedades a traducir para todas las entidades, cada una de tipo String, y pasa elementos String individuales hacia abajo.
Finalmente, la directiva @strTranslate recibe un elemento de tipo String contenido dentro del JSONObject, y lo traduce justo ahí, dentro del propio JSONObject.
Por favor fíjate cuán flexible es esta solución. Simplemente proporcionando la ruta al string dentro del JSONObject es suficiente para acceder al valor, modificarlo con @strTranslate (o cualquier otra directiva), y posiblemente incluso almacenar el valor de nuevo en la BD (el trabajo para lograr esto está actualmente en progreso).
Ya funciona para core/list, ya que todos los elementos de la lista pueden alcanzarse bajo su propia ruta (items.0 es el 1er elemento en el array, etc). Entonces, puede acceder al valor String de cada uno, y pasarlo a @strTranslate, así que no hay necesidad de crear @strTranslateList.
De forma similar, también funcionará con core/table. Solo necesitamos exponer los datos vía la propiedad cells, que será un array de 2 dimensiones (una para filas, conteniendo una para columnas). Luego, translatablePaths puede alcanzar todos los elementos como ["cells.0.0", "cells.0.1", "cells.1.0", ...].
Y funcionará para cualquier bloque de terceros también. Para ello, debemos prestar atención a cómo se almacenan los datos del bloque, y a partir de ahí podemos deducir la ruta a sus propiedades.
Un único Block requiere configuración, basada en código PHP
Mapear los bloques, para que sepamos dónde encontrar sus propiedades de metadatos, puede lograrse mediante configuración. Así que podemos tratarlo de una forma muy flexible.
En Gutenberg, hay dos lugares donde una propiedad del bloque puede almacenarse: como atributo, o dentro del contenido renderizado.
Por ejemplo, así es como se almacena el bloque core/image:
<!-- wp:image {"id":1670,"sizeSlug":"large","linkDestination":"none"} -->
<figure class="wp-block-image size-large">
<img src="https://newapi.getpop.org/wp/wp-content/uploads/2021/01/dynamic-include-first-query.webp" alt="" class="wp-image-1670"/>
</figure>
<!-- /wp:image -->En este caso, tenemos:
- Las propiedades
id,sizeSlugylinkDestinationse almacenan como atributos - La propiedad
srcse almacena dentro del contenido renderizado
Ahora, al consultar la API, la respuesta para el bloque core/image será la siguiente:
{
"data": {
"blocks": [
{
"name": "core/image",
"meta": {
"id": 1670,
"sizeSlug": "large",
"linkDestination": "none",
"src": "https://newapi.getpop.org/wp/wp-content/uploads/2021/01/dynamic-include-first-query.webp"
}
}
]
}
}La API sabe cómo recuperar las propiedades parseando el bloque almacenado en Gutenberg (esa es la estrategia COPE). Este proceso se puede hacer automáticamente hasta cierto punto, y luego algo de entrada manual vía hooks, o a través de alguna interfaz de usuario.
Obtener las propiedades directamente mapeadas como atributos es trivial. El servidor GraphQL puede ya recuperar todos los atributos del bloque, y hacerlos disponibles como propiedades. O, si queremos definir explícitamente cuáles exponer, podemos hacerlo vía filter hooks:
$attrs = apply_filters("blockPropsAsAttr:core/image", []);
add_filter("blockPropsAsAttr:core/image", function ($attrs) {
return array_merge($attrs, ['id', 'sizeSlug', 'linkDestination']);
})Las propiedades almacenadas en el contenido pueden extraerse mediante alguna regex:
$propRegexes = apply_filters("blockPropsAsRegex:core/image", []);
add_filter("blockPropsAsRegex:core/image", function ($propRegexes) {
$propRegexes['src'] = '/<img src="(.*?)"/';
return $propRegexes;
})Finalmente, indicamos cuáles son las propiedades traducibles del bloque, para que @strTranslate actúe sobre ellas:
$propRegexes = apply_filters("translatableProperties:core/image", []);
add_filter("translatableProperties:core/image", function ($properties) {
$properties[] = 'caption';
return $properties;
})Ahora, estas propiedades aún deben ser satisfechas por alguien, lo más probable el desarrollador del plugin. Por lo tanto, tener el registro en el lado del servidor ayudará a lograr este objetivo.
Pero, ¿qué pasa si la comunidad WordPress no quiere añadir el registro propuesto en el lado del servidor? Bueno, esta estrategia puede adaptarse fácilmente, porque el mapeo puede hacerse vía código PHP, como acabamos de mostrar.
Si algún bloque no ha sido mapeado, el usuario también puede hacerlo, sabiendo solo un poco sobre Gutenberg, y nada sobre GraphQL o esquemas.
Además, podemos hacer que GraphQL alerte al usuario cuando hay un bloque que no ha sido mapeado (y por tanto no puede traducirse). Podemos hacer esto añadiendo una meta-directiva @if que, si la condición se cumple, ejecuta la directiva @sendEmail:
{
post(by: { id: 1 }) {
blocks {
name
meta
@advancePointersInArray(paths: "{{ translatablePaths }}")
@underEachArrayItem
@strTranslate(from: "en", to: "fr")
@if(condition: "{{ isTranslatablePathsUnmapped }}")
@sendEmail(
to: "{{ root.adminEmail }}",
subject: "Block with name {{ name }} has 'translatablePaths' unmapped"
)
}
}
}Esta solución es flexible y simple, y tiene a GraphQL sirviendo a WordPress, sin requerir que los desarrolladores aprendan una nueva tecnología, ni cambiando cómo funciona Gutenberg.
Conclusión
Al pensar en cómo se verá una posible integración entre GraphQL y Gutenberg (desde una potencial inclusión en el core de WordPress), debemos asegurarnos de que GraphQL pueda manejar todos los requisitos futuros de Gutenberg, incluyendo soporte completo para:
- bloques multilingües
- Full Site Editing
- edición colaborativa
- interactuar con servicios de terceros en un sitio en producción
Todo esto debe lograrse, esperemos, sin necesidad de cambiar Gutenberg (al menos, no de forma considerable), y reduciendo las nuevas tareas requeridas a los desarrolladores de plugins.
Teniendo esto en cuenta, creo que el 4º enfoque que estoy sugiriendo aquí puede de hecho funcionar muy bien.