💬 Propondo uma nova abordagem para 'Gutenberg e Aplicações Desacopladas'
Alguns dias atrás, o criador do WPGraphQL, Jason Bahl, publicou Gutenberg and Decoupled Applications, analisando os benefícios e limitações de 3 abordagens para integrar GraphQL com Gutenberg.
Uma semana antes, ele também havia dito no Twitter que a abordagem do Gato GraphQL para modelar Gutenberg é inadequada:
Isso não é algo para se orgulhar, na minha opinião. Uma coisa que GraphQL tenta resolver com um Schema tipado é fornecer previsibilidade e consistência para os clientes, e dar a eles controle para pedir o que querem, até o nível do campo.
Retornar um tipo "Object" genérico sem forma previsível significa que as aplicações cliente podem quebrar a qualquer momento, porque não existe mais um contrato entre o servidor e o cliente. O servidor agora tirou o controle do cliente.
Por meio deste artigo, participo da conversa. Vou responder à crítica de Jason e, ao fazê-lo, descrever a abordagem do meu plugin e mostrar por que acredito que ela pode funcionar muito bem com Gutenberg.
Usando COPE para extrair metadados do Gutenberg
Minha solução poderia ser considerada a 4ª abordagem, e é a seguinte:
Para obter os dados do Gutenberg que alimentarão o GraphQL, não crie um schema adicional no lado PHP, nem duplique dados existentes. Em vez disso, extraia os dados do conteúdo armazenado dos blocos, usando a estratégia COPE ("Create Once, Publish Everywhere").
(COPE é uma estratégia que permite ter uma única fonte de verdade do conteúdo e expô-la a diferentes aplicações. No nosso caso, a única fonte de verdade são os dados dos blocos Gutenberg, tal como estão armazenados no banco de dados. Descrevi o COPE e sua implementação para WordPress neste artigo.)
Por fim, podemos usar GraphQL para recuperar os dados extraídos, para qualquer bloco Gutenberg, mapeando todos os blocos para um único tipo Block.
Esta estratégia é um compromisso, não uma solução definitiva
Esta estratégia não resolve o problema que Jason aponta: a ausência de um schema no lado do servidor, que permitiria a criação de um contrato entre o servidor e o cliente.
O COPE não pode resolver esse problema porque, apenas a partir do conteúdo armazenado, não conseguimos recriar o schema:
- O conteúdo armazenado não indica o tipo do campo
- O conteúdo armazenado não indica quais restrições o campo possui (é nullable? é um inteiro positivo? a string é para um e-mail ou uma URL?)
- Campos nullable podem ter um valor padrão, que não estará presente no conteúdo armazenado
No entanto, usando a estratégia COPE e um único tipo Block para representar todos os blocos, o Gato GraphQL pode construir uma integração muito boa com Gutenberg, superando as limitações existentes.
Explicarei ao longo deste artigo.
Integração do Gato GraphQL com Gutenberg
Esta solução está em desenvolvimento, mas já posso explicar como ela se comportará.
Em vez de depender de um tipo diferente por bloco (como o WPGraphQL faz ao usar o plugin WPGraphQL for Gutenberg), o Gato GraphQL fornecerá um único tipo Block para representar todos os blocos.
Nesta query, o campo Post.blockDataItems recupera uma lista de elementos Block do post (para diferentes blocos Gutenberg, incluindo parágrafos, imagens, listas e outros):
{
post(by: { id: 1499 }) {
title
blockDataItems
}
}Se quisermos recuperar dados de um bloco específico, podemos filtrar pelo nome do bloco (core/paragraph, core/quote, etc).
Nesta query, recuperamos apenas os blocos de imagem:
{
post(by: { id: 1177 }) {
title
blockDataItems(
filterBy: { include: "core/image" }
)
}
}Inspecionando o tipo único Block
Com esta abordagem, a resposta pode variar dependendo do conteúdo armazenado, não de um schema. Essa qualidade é ao mesmo tempo sua vantagem (pois torna a API flexível) e sua desvantagem (não podemos impor contratos servidor-cliente).
Cada elemento Block contém duas propriedades:
name: O nome do bloco (core/paragraph,core/quote, etc)meta: Os metadados contidos no bloco
Cada bloco Gutenberg é diferente, contendo dados distintos (o conteúdo de um parágrafo, um vídeo do Youtube, a URL de origem de uma imagem e suas dimensões, etc). Portanto, os dados contidos na resposta para o campo meta também serão diferentes.
Sendo assim, o campo meta foi mapeado simplesmente como um objeto JSON (que pode conter dados "brutos"), por meio de um tipo JSONObject correspondente no schema GraphQL.
Isso produz a seguinte resposta:
{
"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, temos diferentes blocos recuperando diferentes propriedades:
core/paragraphtem a propriedadecontentcore/imagetem a propriedadesrc, e opcionalmente as propriedadeswidth,heightecaption(não aparecendo na resposta acima)core/quotetem as propriedadesquoteecite(para a pessoa citada)core/headingtem as propriedadesheaderesize(o valorxlrepresenta<h2>, porque o COPE desacopla o valor da aplicação de destino, neste caso um site)core/listtem a propriedadeitems, que é uma lista de elementos
Por que o tipo JSONObject não faz parte da spec
O tipo JSONObject que descrevi acima permite que o GraphQL recupere campos "dinâmicos" (como campos que não conhecemos), ou campos que podem ter múltiplas configurações (como pode ser o caso com blocos Gutenberg).
Agora, a spec GraphQL atualmente não suporta os tipos JSONObject ou Map. O suporte a eles foi solicitado, por razões como:
[...] a ausência dessa funcionalidade é particularmente problemática porque ela é suportada em muitos dos sistemas de tipos e serviços com os quais o GraphQL interage.
Isso leva a implementar resolvers personalizados no servidor, seguidos de transformações personalizadas no cliente, para lidar com situações em que meu servidor está enviando um Map, e meu cliente quer um Map, e o GraphQL está no meio sem suporte para Maps. Sim, é possível, e eu fiz isso, mas é bastante boilerplate e abstração que parece derrotar o propósito de escrever a spec da API em GraphQL.
Essa funcionalidade não é suportada pela spec porque lidar com campos dinâmicos vai contra o comportamento de tipagem forte do GraphQL, que quebra o contrato entre o servidor e o cliente.
Ainda assim, esse tipo pode ser benéfico para o Gutenberg, como mostrarei adiante.
Problemas ao usar um tipo diferente por bloco e um registro no lado do servidor
Se criarmos um novo tipo GraphQL por bloco, então todos os plugins precisarão ter seus blocos adicionados ao schema GraphQL. Isso poderia ser realizado automaticamente fazendo com que todos os blocos definam suas propriedades no novo registro no lado do servidor proposto.
Se não o fizerem, seus blocos ficarão indisponíveis para a API, e isso pode ter consequências adicionais. Em algumas circunstâncias, todo o conteúdo do post consultado pode se tornar não confiável.
Esse pode ser o caso quando o GraphQL interage com um serviço externo baseado em nuvem, que aplica alguma função a todos os blocos do post (pense em tradução, correção gramatical, sugestões de SEO, análises, etc).
Vejamos um exemplo disso.
Como as capacidades multilíngues serão adicionadas ao Gutenberg na fase 4, vamos modelar como traduzir todos os blocos do plugin, por meio de uma chamada à API Google Translate executada via diretiva @strTranslate.
(Após esta tradução inicial baseada em API, o usuário pode continuar editando o post do blog, no idioma traduzido, sempre dentro do editor WordPress.)
Blocos diferentes contêm diferentes informações que devem ser traduzidas:
core/paragraph: o textocore/image: a legendacore/quote: a citação, e a pessoa citada (pois pode ser o título da pessoa, como "The school headmaster")core/heading: o cabeçalhocore/list: todos os itens da lista
Usando um tipo diferente por bloco, a query resultante pode ser algo assim:
{
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
}
}
}
}E assim por diante. Quanto mais blocos tivermos, mais longa será essa query, podendo facilmente chegar a centenas de linhas ou mais.
O problema óbvio é que a query se torna uma fera difícil de manter.
Além disso, precisamos introduzir funcionalidades personalizadas para fazê-la funcionar com cada bloco. Por exemplo, @strTranslate não funciona com CoreListBlock.items, que retorna uma lista de strings (ou seja, retorna [String], enquanto a diretiva espera String), e então precisamos criar @strTranslateList.
E então core/table precisaria de sua própria diretiva personalizada (@strTranslateTable?).
E blocos de terceiros personalizados podem precisar de suas próprias diretivas personalizadas.
E então, vejo mais alguns problemas.
É tudo ou nada
Um post do blog pode conter qualquer bloco instalado no editor WordPress. E não sabemos com antecedência (ao escrever a query) quais blocos o post utiliza.
Então, com um tipo por bloco, o número de tipos a serem tratados na query não será equivalente ao número de blocos no post. Em vez disso, será equivalente ao número de blocos instalados no editor WordPress.
O que acontece se tivermos 100 blocos em nosso site, incluindo tanto os do core do WordPress quanto os de plugins? Então precisamos ter 100 tipos mapeados no schema GraphQL. Um único que não está mapeado pode quebrar o "contrato de conteúdo", resultando em alguns blocos sendo traduzidos do inglês para o francês, enquanto outros permanecem em inglês.
Como resultado, não poderemos mais confiar nos posts traduzidos, independentemente de eles conterem ou não o bloco problemático. Portanto, se nem todos os blocos forem adicionados ao registro, a aplicação pode se tornar não confiável.
A query deve ser atualizada toda vez que um novo bloco é instalado
Da mesma forma, cada bloco deve ser tratado na query GraphQL. Isso significa que, sempre que instalarmos um novo bloco, precisamos ir ao código da nossa aplicação, atualizá-lo e reimplantá-lo.
Isso não é apenas burocracia extra: não conseguiremos instalar um bloco em um site em produção sem o medo de quebrar a aplicação (até que todas as queries sejam atualizadas).
GraphQL deve servir ao WordPress, não o contrário
Considerando novamente por que o JSONObject não foi adicionado à spec GraphQL, é porque ele não se encaixa na forma de fazer as coisas do GraphQL.
No entanto, aqui não estamos verdadeiramente preocupados com o GraphQL. Nos preocupamos apenas com o WordPress e, mais especificamente neste caso, com o Gutenberg.
Ao integrar GraphQL com Gutenberg, o GraphQL operará dentro do contexto do WordPress. Isso significa que o WordPress precisará satisfazer os requisitos do GraphQL. Mas, mais importante, é o GraphQL que precisa satisfazer os requisitos do WordPress.
E em caso de conflito, o WordPress tem prioridade.
Se uma funcionalidade não se encaixa no GraphQL, mas ainda assim se encaixa no Gutenberg, ela deve ser considerada?
Acho que sim.
Vejamos como um único tipo Block pode servir melhor ao Gutenberg.
Resolvendo os problemas anteriores com um único tipo Block
Seguindo o exemplo anterior, traduzir todos os blocos de um post do inglês para o francês, usando um único tipo Block, será feito assim (ou algo em torno desse conceito):
{
post(by: { id: 1 }) {
blocks {
name
meta
@advancePointersInArray(paths: "{{ translatablePaths }}")
@underEachArrayItem
@strTranslate(from: "en", to: "fr")
}
}
}Só isso? A query inteira? Para traduzir todos os blocos? Sim.
Vai funcionar para todos os blocos, tanto do core quanto de plugins, já existentes ou ainda a serem criados? Sim.
Essa query parece um pouco estranha para você? Se sim, é porque ela usa funcionalidades GraphQL não padrão, suportadas apenas pelo Gato GraphQL:
{{ translatablePaths }}é um campo incorporável, para inserir o valor de um campo como argumento de outro campo ou diretiva (neste caso, o tipoBlockterá um campotranslatableFields, cujo valor é injetado na diretiva@advancePointersInArray)- as diretivas podem ser compostas por outras diretivas
Agora, se uma funcionalidade satisfaz exatamente o que o CMS precisa, mas é não padrão, ainda devemos usá-la? Acho que sim.
Também solicitei essas funcionalidades para a spec GraphQL (mesmo que não sejam aceitas):
Como o tipo único Block funciona
Aviso: seção técnica a seguir.
O tipo Block terá um campo translatablePaths, retornando um array das propriedades do JSONObject que devem ser traduzidas:
core/paragraphretorna["content"]core/imageretorna["caption"]core/quoteretorna["quote", "cite"]core/headingretorna["header"]core/listretorna["items.0", "items.1", "items.2", ...]
@advancePointersInArray é uma meta-diretiva: ela modifica o contexto para uma diretiva subsequente. Faz com que a diretiva subsequente receba um subelemento de dentro do JSONObject consultado, como a propriedade content do bloco de parágrafo. A lista de caminhos é obtida via campo translatablePaths, avaliado na mesma entidade consultada.
Em seguida, @underEachArrayItem é outra meta-diretiva, que itera sobre uma lista de elementos da entidade consultada e passa uma referência ao elemento iterado para a próxima diretiva. Neste caso, obtém toda a lista de propriedades a serem traduzidas para todas as entidades, cada uma do tipo String, e passa elementos String individuais adiante.
Por fim, a diretiva @strTranslate recebe um elemento do tipo String contido no JSONObject, e o traduz ali mesmo, dentro do próprio JSONObject.
Observe como essa solução é flexível. Apenas fornecendo o caminho para a string dentro do JSONObject é suficiente para acessar o valor, modificá-lo com @strTranslate (ou qualquer outra diretiva), e possivelmente até armazenar o valor novamente no banco de dados (o trabalho para realizar isso está atualmente em andamento).
Já funciona para core/list, pois todos os elementos da lista podem ser acessados por seu próprio caminho (items.0 é o 1º elemento no array, e assim por diante). Então, pode acessar o valor String de cada um e passá-lo para @strTranslate, portanto não há necessidade de criar @strTranslateList.
Da mesma forma, também funcionará com core/table. Precisamos apenas expor os dados via propriedade cells, que será um array de 2 dimensões (uma para linhas, contendo uma para colunas). Então, translatablePaths pode alcançar todos os elementos como ["cells.0.0", "cells.0.1", "cells.1.0", ...].
E também funcionará para qualquer bloco de terceiros. Para isso, devemos prestar atenção em como os dados do bloco são armazenados, e a partir daí podemos deduzir o caminho para suas propriedades.
Um único Block requer configuração, baseada em código PHP
Mapear os blocos, para sabermos onde encontrar suas propriedades de metadados, pode ser realizado por meio de configuração. Assim, podemos lidar com isso de uma forma muito flexível.
No Gutenberg, há dois lugares onde uma propriedade do bloco pode ser armazenada: como um atributo ou dentro do conteúdo renderizado.
Por exemplo, é assim que o bloco core/image é armazenado:
<!-- 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 -->Neste caso, temos:
- As propriedades
id,sizeSlugelinkDestinationsão armazenadas como atributos - A propriedade
srcé armazenada dentro do conteúdo renderizado
Agora, ao consultar a API, a resposta para o bloco core/image será a seguinte:
{
"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"
}
}
]
}
}A API sabe como recuperar as propriedades analisando o bloco armazenado no Gutenberg (essa é a estratégia COPE). Esse processo pode ser feito automaticamente até certo ponto, e então com alguma entrada manual via hooks, ou por meio de uma interface de usuário.
Obter as propriedades diretamente mapeadas como atributos é trivial. O servidor GraphQL já pode recuperar todos os atributos do bloco e disponibilizá-los como propriedades. Ou, se quisermos definir explicitamente quais expor, podemos fazê-lo via filter hooks:
$attrs = apply_filters("blockPropsAsAttr:core/image", []);
add_filter("blockPropsAsAttr:core/image", function ($attrs) {
return array_merge($attrs, ['id', 'sizeSlug', 'linkDestination']);
})As propriedades armazenadas no conteúdo podem ser extraídas via regex:
$propRegexes = apply_filters("blockPropsAsRegex:core/image", []);
add_filter("blockPropsAsRegex:core/image", function ($propRegexes) {
$propRegexes['src'] = '/<img src="(.*?)"/';
return $propRegexes;
})Por fim, indicamos quais são as propriedades traduzíveis do bloco, para que @strTranslate atue sobre elas:
$propRegexes = apply_filters("translatableProperties:core/image", []);
add_filter("translatableProperties:core/image", function ($properties) {
$properties[] = 'caption';
return $properties;
})Agora, essas propriedades ainda precisam ser satisfeitas por alguém, muito provavelmente o desenvolvedor do plugin. Portanto, ter o registro no lado do servidor ajudará a atingir esse objetivo.
Mas e se a comunidade WordPress não quiser adicionar o registro no lado do servidor proposto? Bem, essa estratégia pode se adaptar facilmente, porque o mapeamento pode ser feito via código PHP, como acabamos de mostrar.
Se algum bloco não foi mapeado, o usuário também pode fazê-lo, sabendo apenas um pouco sobre Gutenberg, e nada sobre GraphQL ou schemas.
Além disso, podemos fazer com que o GraphQL alerte o usuário quando houver um bloco que não foi mapeado (e portanto não pode ser traduzido). Podemos fazer isso adicionando uma meta-diretiva @if que, se a condição se aplicar, executa a diretiva @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 solução é flexível e simples, e faz o GraphQL servir ao WordPress, sem exigir que os desenvolvedores aprendam uma nova tecnologia, ou que mudem a forma como o Gutenberg funciona.
Conclusão
Ao pensar em como seria uma possível integração entre GraphQL e Gutenberg (pensando em uma eventual inclusão no core do WordPress), devemos garantir que o GraphQL possa lidar com todos os requisitos futuros do Gutenberg, incluindo suporte completo para:
- blocos multilíngues
- Full Site Editing
- edição colaborativa
- interação com serviços de terceiros em um site em produção
Tudo isso deve ser realizado preferencialmente sem precisar alterar o Gutenberg (pelo menos, não de forma considerável), e reduzindo as novas tarefas exigidas dos desenvolvedores de plugins.
Levando isso em conta, acredito que a 4ª abordagem que estou sugerindo aqui pode funcionar muito bem.