💬 Proporre un nuovo approccio per 'Gutenberg e le applicazioni disaccoppiate'
Qualche giorno fa, il creatore di WPGraphQL, Jason Bahl, ha pubblicato Gutenberg and Decoupled Applications, analizzando i vantaggi e gli svantaggi di 3 approcci per integrare GraphQL con Gutenberg.
Una settimana prima, aveva anche dichiarato su Twitter che l'approccio di Gato GraphQL per modellare Gutenberg è inappropriato:
Non è qualcosa di cui vantarsi, secondo me. Una cosa che GraphQL cerca di risolvere con uno schema tipizzato è fornire prevedibilità e coerenza ai client, e dare loro il controllo per chiedere ciò che vogliono, fino al livello del campo.
Restituire un tipo "Object" generico senza una forma prevedibile significa che le applicazioni client possono rompersi in qualsiasi momento, perché non esiste più un contratto tra il server e il client. Il server ha ora tolto il controllo al client.
Attraverso questo articolo, mi unisco alla conversazione. Risponderò alla critica di Jason e, così facendo, descriverò l'approccio del mio plugin, e mostrerò perché credo che possa in realtà adattarsi molto bene a Gutenberg.
Usare COPE per estrarre i metadati di Gutenberg
La mia soluzione potrebbe essere considerata il 4° approccio, ed è la seguente:
Per ottenere i dati Gutenberg che alimenteranno GraphQL, non creare uno schema aggiuntivo lato PHP, né duplicare dati esistenti. Al suo posto, estrarre i dati dal contenuto memorizzato dei blocchi, utilizzando la strategia COPE ("Create Once, Publish Everywhere").
(COPE è una strategia che permette di avere un'unica fonte di verità del contenuto, e di esporlo a diverse applicazioni. Nel nostro caso, l'unica fonte di verità sono i dati dei blocchi Gutenberg, così come sono memorizzati nel database. Ho descritto COPE e la sua implementazione per WordPress in questo articolo.)
Infine, possiamo usare GraphQL per recuperare i dati estratti, per qualsiasi blocco Gutenberg, mappando tutti i blocchi su un unico tipo Block.
Questa strategia è un compromesso, non una soluzione definitiva
Questa strategia non risolve il problema che Jason solleva: l'assenza di uno schema lato server, che permetterebbe la creazione di un contratto tra il server e il client.
COPE non può risolvere questo problema perché, solo a partire dal contenuto memorizzato, non possiamo ricreare lo schema:
- Il contenuto memorizzato non indica il tipo del campo
- Il contenuto memorizzato non indica quali restrizioni ha il campo (è nullable? è un intero positivo? la stringa è per un'e-mail o per un URL?)
- I campi nullable possono avere un valore di default, che non sarà presente nel contenuto memorizzato
Tuttavia, utilizzando la strategia COPE e un unico tipo Block per rappresentare tutti i blocchi, Gato GraphQL può costruire un'integrazione molto valida con Gutenberg, che supera i limiti esistenti.
Lo spiegherò nel corso di questo articolo.
L'integrazione di Gato GraphQL con Gutenberg
Questa soluzione è un lavoro in corso, ma posso già spiegare come si comporterà .
Invece di dipendere da un tipo diverso per blocco (come fa WPGraphQL appoggiandosi al plugin WPGraphQL for Gutenberg), Gato GraphQL fornirà un unico tipo Block per rappresentare tutti i blocchi.
In questa query, il campo Post.blockDataItems recupera un elenco di elementi Block dall'articolo (per diversi blocchi Gutenberg, inclusi paragrafi, immagini, elenchi e altri):
{
post(by: { id: 1499 }) {
title
blockDataItems
}
}Se vogliamo recuperare i dati per un blocco specifico, possiamo filtrare in base al nome del blocco (core/paragraph, core/quote, ecc).
In questa query, recuperiamo solo i blocchi immagine:
{
post(by: { id: 1177 }) {
title
blockDataItems(
filterBy: { include: "core/image" }
)
}
}Ispezione del tipo unico Block
Con questo approccio, la risposta può variare a seconda del contenuto memorizzato, non in base a uno schema. Questa qualità è al tempo stesso il suo vantaggio (poiché rende l'API flessibile) e il suo svantaggio (non possiamo imporre contratti server-client).
Ogni elemento Block contiene due proprietà :
name: Il nome del blocco (core/paragraph,core/quote, ecc)meta: I metadati contenuti nel blocco
Ogni blocco Gutenberg è diverso, contenendo dati diversi (un contenuto di paragrafo, un video YouTube, un URL sorgente di un'immagine e le sue dimensioni, ecc). Di conseguenza, anche i dati contenuti nella risposta per il campo meta saranno diversi.
In quanto tale, il campo meta è stato mappato semplicemente come un oggetto JSON (che può contenere dati "grezzi"), tramite un tipo JSONObject corrispondente nello schema GraphQL.
Produce questa risposta:
{
"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"
]
}
},
]
}
}
}Come possiamo vedere, abbiamo diversi blocchi che recuperano diverse proprietà :
core/paragraphha la proprietÃcontentcore/imageha la proprietÃsrc, e opzionalmente le proprietÃwidth,heightecaption(non presenti nella risposta sopra)core/quoteha le proprietÃquoteecite(per la persona citata)core/headingha le proprietÃheaderesize(il valorexlrappresenta<h2>, perché COPE disaccoppia il valore dall'applicazione di destinazione, in questo caso un sito web)core/listha la proprietÃitems, che è un elenco di elementi
Perché il tipo JSONObject non fa parte della spec
Il tipo JSONObject che ho descritto sopra permette a GraphQL di recuperare campi "dinamici" (come campi che non conosciamo), o campi che possono avere più configurazioni (come può accadere con i blocchi Gutenberg).
Ora, la spec GraphQL attualmente non supporta i tipi JSONObject o Map. È stato richiesto di aggiungerne il supporto, per ragioni come:
[...] l'assenza di questa funzionalità è particolarmente problematica perché è supportata in molti dei sistemi di tipi e servizi con cui GraphQL si interfaccia.
Questo porta a implementare resolver personalizzati sul server, seguiti da trasformazioni personalizzate sul client, per gestire situazioni in cui il mio server invia una Map, e il mio client vuole una Map, e GraphQL è nel mezzo senza supporto per le Map. Sì, è possibile, e l'ho fatto, ma è un bel po' di boilerplate e di astrazione che sembra vanificare lo scopo di scrivere la spec dell'API in GraphQL.
Questa funzionalità non è supportata dalla spec perché gestire campi dinamici va contro il comportamento di tipizzazione forte di GraphQL, che rompe il contratto tra il server e il client.
Ciononostante, questo tipo può essere utile a Gutenberg, come mostrerò più avanti.
Problemi nell'usare un tipo diverso per blocco, e un registro lato server
Se si crea un nuovo tipo GraphQL per blocco, allora tutti i plugin devono avere i loro blocchi aggiunti allo schema GraphQL. Ciò potrebbe essere realizzato automaticamente facendo in modo che tutti i blocchi definiscano le loro proprietà nel nuovo registro lato server proposto.
Se non lo fanno, i loro blocchi saranno indisponibili per l'API, e questo può avere conseguenze aggiuntive. In alcune circostanze, l'intero contenuto dell'articolo interrogato potrebbe diventare inaffidabile.
Questo può accadere quando GraphQL interagisce con un servizio esterno basato sul cloud, che applica una funzione a tutti i blocchi dell'articolo (pensa alla traduzione, alla correzione grammaticale, ai suggerimenti SEO, alle analisi, ecc).
Vediamo un esempio di questo.
Poiché le capacità multilingue saranno aggiunte a Gutenberg nella fase 4, modelliamo come tradurre tutti i blocchi del plugin, tramite una chiamata all'API Google Translate eseguita attraverso una direttiva @strTranslate.
(Dopo questa traduzione iniziale basata sull'API, l'utente può continuare a modificare l'articolo del blog, nella lingua tradotta, sempre all'interno dell'editor WordPress.)
Blocchi diversi contengono diverse informazioni che devono essere tradotte:
core/paragraph: il testocore/image: la didascaliacore/quote: la citazione, e la persona citata (perché potrebbe essere il titolo della persona, come "The school headmaster")core/heading: l'intestazionecore/list: tutti gli elementi dell'elenco
Usando un tipo diverso per blocco, la query risultante potrebbe assomigliare a questa:
{
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 così via. Più blocchi abbiamo, più lunga sarà questa query, potendo facilmente estendersi su un centinaio di righe e anche di più.
Il problema evidente è che la query diventa una bestia selvaggia che dobbiamo mantenere.
Inoltre, dobbiamo introdurre funzionalità personalizzate per farla funzionare con ogni blocco. Per esempio, @strTranslate non funziona con CoreListBlock.items, che restituisce un elenco di stringhe (cioè restituisce [String], mentre la direttiva si aspetta String), e quindi dobbiamo creare @strTranslateList.
E poi core/table necessiterebbe della sua direttiva personalizzata (@strTranslateTable?).
E i blocchi personalizzati di terze parti potrebbero aver bisogno delle loro direttive personalizzate.
E poi, vedo un paio di problemi in più.
È tutto o niente
Un articolo del blog può contenere qualsiasi blocco installato nell'editor WordPress. E non sappiamo in anticipo (quando scriviamo la query) quali blocchi l'articolo utilizza.
Allora, con un tipo per blocco, il numero di tipi da gestire nella query non sarà equivalente al numero di blocchi nell'articolo. Sarà piuttosto equivalente al numero di blocchi installati nell'editor WordPress.
Cosa succede se abbiamo 100 blocchi sul nostro sito, includendo sia quelli del core di WordPress sia quelli dei plugin? Allora dobbiamo avere 100 tipi mappati sullo schema GraphQL. Uno solo che non è mappato può rompere il "contratto di contenuto", facendo sì che alcuni blocchi siano tradotti dall'inglese al francese, mentre altri rimangono in inglese.
Di conseguenza, non potremo più fidarci degli articoli tradotti, che contengano o meno il blocco problematico. Quindi, se non tutti i blocchi vengono aggiunti al registro, l'applicazione può diventare inaffidabile.
La query deve essere aggiornata ogni volta che un nuovo blocco viene installato
Allo stesso modo, ogni blocco deve essere gestito nella query GraphQL. Ciò significa che, ogni volta che installiamo un nuovo blocco, dobbiamo andare nel codice della nostra applicazione, aggiornarlo e ridistribuirlo.
Non è solo burocrazia in più: non potremo installare un blocco su un sito in produzione, senza il timore di rompere l'applicazione (finché tutte le query non saranno aggiornate).
GraphQL deve servire WordPress, non il contrario
Considerando di nuovo perché JSONObject non è stato aggiunto alla spec GraphQL, è perché non si adatta al modo di fare le cose di GraphQL.
Tuttavia, qui non ci preoccupiamo veramente di GraphQL. Ci preoccupiamo solo di WordPress e, più specificamente in questo caso, di Gutenberg.
Quando si integra GraphQL con Gutenberg, GraphQL opererà nel contesto di WordPress. Ciò significa che WordPress dovrà soddisfare i requisiti di GraphQL. Ma cosa più importante, è GraphQL che deve soddisfare i requisiti di WordPress.
E in caso di conflitto, WordPress ha la priorità .
Se una funzionalità non si adatta a GraphQL, ma si adatta comunque a Gutenberg, dovrebbe essere considerata?
Penso di sì.
Vediamo come un unico tipo Block può servire meglio Gutenberg.
Risolvere i problemi precedenti tramite un unico tipo Block
Seguendo l'esempio precedente, tradurre tutti i blocchi di un articolo dall'inglese al francese, usando un unico tipo Block, si farà così (o qualcosa di simile a questo concetto):
{
post(by: { id: 1 }) {
blocks {
name
meta
@advancePointersInArray(paths: "{{ translatablePaths }}")
@underEachArrayItem
@strTranslate(from: "en", to: "fr")
}
}
}Tutto qui? L'intera query? Per tradurre tutti i blocchi? Sì.
Funzionerà per tutti i blocchi, sia del core sia dei plugin, già esistenti o ancora da creare? Sì.
Questa query ti sembra un po' strana? Se è così, è perché usa funzionalità GraphQL non standard, supportate solo da Gato GraphQL:
{{ translatablePaths }}è un campo incorporabile, per inserire il valore di un campo come argomento di un altro campo o direttiva (in questo caso, il tipoBlockavrà un campotranslatableFields, il cui valore viene iniettato nella direttiva@advancePointersInArray)- le direttive possono essere composte da altre direttive
Ora, se una funzionalità soddisfa esattamente ciò di cui il CMS ha bisogno, ma la funzionalità è non standard, dovremmo comunque usarla? Penso di sì.
Ho anche richiesto queste funzionalità per la spec GraphQL (anche se non saranno accettate):
Come funziona il tipo unico Block
Avvertenza: in arrivo una sezione tecnica.
Il tipo Block avrà un campo translatablePaths, che restituisce un array delle proprietà del JSONObject che devono essere tradotte:
core/paragraphrestituisce["content"]core/imagerestituisce["caption"]core/quoterestituisce["quote", "cite"]core/headingrestituisce["header"]core/listrestituisce["items.0", "items.1", "items.2", ...]
@advancePointersInArray è una meta-direttiva: modifica il contesto per una direttiva successiva. Fa in modo che la direttiva successiva riceva un sotto-elemento dal JSONObject interrogato, come la proprietà content del blocco paragrafo. L'elenco dei percorsi si ottiene tramite il campo translatablePaths, valutato sulla stessa entità interrogata.
Poi, @underEachArrayItem è un'altra meta-direttiva, che itera su un elenco di elementi dell'entità interrogata, e passa un riferimento all'elemento iterato alla direttiva successiva. In questo caso, ottiene tutto l'elenco delle proprietà da tradurre per tutte le entità , ciascuna di tipo String, e passa singoli elementi String lungo la catena.
Infine, la direttiva @strTranslate riceve un elemento di tipo String contenuto nel JSONObject, e lo traduce proprio lì, all'interno del JSONObject stesso.
Nota quanto è flessibile questa soluzione. Basta fornire il percorso verso la stringa all'interno del JSONObject per accedere al valore, modificarlo con @strTranslate (o qualsiasi altra direttiva), ed eventualmente persino memorizzare di nuovo il valore nel database (il lavoro per realizzare questo è attualmente in corso).
Funziona già per core/list, poiché tutti gli elementi dell'elenco possono essere raggiunti sotto il loro percorso (items.0 è il 1° elemento dell'array, e così via). Poi, può accedere al valore String di ciascuno, e passarlo a @strTranslate, quindi non c'è bisogno di creare @strTranslateList.
Allo stesso modo, funzionerà anche con core/table. Abbiamo solo bisogno di esporre i dati tramite la proprietà cells, che sarà un array a 2 dimensioni (una per le righe, contenente una per le colonne). Poi, translatablePaths può raggiungere tutti gli elementi come ["cells.0.0", "cells.0.1", "cells.1.0", ...].
E funzionerà anche per qualsiasi blocco di terze parti. Per questo, dobbiamo prestare attenzione a come sono memorizzati i dati del blocco, e da lì possiamo dedurre il percorso verso le sue proprietà .
Un unico Block richiede una configurazione, basata su codice PHP
Mappare i blocchi, in modo da sapere dove trovare le loro proprietà di metadati, può essere realizzato tramite la configurazione. Quindi possiamo gestirlo in modo molto flessibile.
In Gutenberg, ci sono due posti in cui una proprietà del blocco può essere memorizzata: come attributo, o all'interno del contenuto renderizzato.
Per esempio, ecco come è memorizzato il blocco 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 -->In questo caso, abbiamo:
- Le proprietÃ
id,sizeSlugelinkDestinationsono memorizzate come attributi - La proprietÃ
srcè memorizzata all'interno del contenuto renderizzato
Ora, quando interroghiamo l'API, la risposta per il blocco core/image sarà la seguente:
{
"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"
}
}
]
}
}L'API sa come recuperare le proprietà analizzando il blocco memorizzato in Gutenberg (questa è la strategia COPE). Questo processo può essere effettuato automaticamente fino a un certo grado, e poi con qualche inserimento manuale tramite hook, o tramite un'interfaccia utente.
Ottenere le proprietà direttamente mappate come attributi è banale. Il server GraphQL può già recuperare tutti gli attributi dal blocco, e renderli disponibili come proprietà . Oppure, se vogliamo definire esplicitamente quali esporre, possiamo farlo tramite filter hook:
$attrs = apply_filters("blockPropsAsAttr:core/image", []);
add_filter("blockPropsAsAttr:core/image", function ($attrs) {
return array_merge($attrs, ['id', 'sizeSlug', 'linkDestination']);
})Le proprietà memorizzate nel contenuto possono essere estratte tramite una regex:
$propRegexes = apply_filters("blockPropsAsRegex:core/image", []);
add_filter("blockPropsAsRegex:core/image", function ($propRegexes) {
$propRegexes['src'] = '/<img src="(.*?)"/';
return $propRegexes;
})Infine, indichiamo quali sono le proprietà traducibili del blocco, su cui @strTranslate deve agire:
$propRegexes = apply_filters("translatableProperties:core/image", []);
add_filter("translatableProperties:core/image", function ($properties) {
$properties[] = 'caption';
return $properties;
})Ora, queste proprietà devono comunque essere soddisfatte da qualcuno, molto probabilmente lo sviluppatore del plugin. Da qui l'interesse di avere il registro lato server per raggiungere questo obiettivo.
Ma cosa succede se la comunità WordPress non vuole aggiungere il registro lato server proposto? Beh, questa strategia può adattarsi facilmente, perché il mapping può essere fatto tramite codice PHP, come appena mostrato.
Se un blocco non è stato mappato, anche l'utente può farlo, sapendo solo un po' di Gutenberg, e nulla su GraphQL o sugli schemi.
Inoltre, possiamo fare in modo che GraphQL avvisi l'utente quando c'è un blocco che non è stato mappato (e quindi non può essere tradotto). Possiamo farlo aggiungendo una meta-direttiva @if che, se la condizione si applica, esegue la direttiva @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"
)
}
}
}Questa soluzione è flessibile e semplice, e fa in modo che GraphQL serva WordPress, senza richiedere agli sviluppatori di imparare una nuova tecnologia, né di modificare il funzionamento di Gutenberg.
Conclusione
Quando riflettiamo su come potrebbe apparire una possibile integrazione tra GraphQL e Gutenberg (nell'ottica di un'eventuale inclusione nel core di WordPress), dobbiamo assicurarci che GraphQL possa gestire tutti i futuri requisiti di Gutenberg, incluso il pieno supporto per:
- i blocchi multilingue
- il Full Site Editing
- l'editing collaborativo
- l'interazione con servizi di terze parti su un sito in produzione
Tutto questo deve essere realizzato idealmente senza bisogno di modificare Gutenberg (almeno, non in modo considerevole), e riducendo i nuovi compiti richiesti agli sviluppatori di plugin.
Tenendo conto di questo, credo che il 4° approccio che suggerisco qui possa davvero funzionare molto bene.