Blog

💬 Proposer une nouvelle approche pour 'Gutenberg et les applications découplées'

Leonardo Losoviz
Par Leonardo Losoviz ·

Il y a quelques jours, le créateur de WPGraphQL, Jason Bahl, a publié Gutenberg and Decoupled Applications, analysant les avantages et les inconvénients de 3 approches pour intégrer GraphQL avec Gutenberg.

Une semaine plus tôt, il avait également déclaré sur Twitter que l'approche de Gato GraphQL pour modéliser Gutenberg était inappropriée :

Ce n'est pas quelque chose dont se vanter, selon moi. Une chose que GraphQL tente de résoudre avec un schéma typé est de fournir prévisibilité et cohérence pour les clients, et de leur donner le contrôle pour demander ce qu'ils veulent, jusqu'au niveau du champ.

Retourner un type "Object" générique sans forme prévisible signifie que les applications clientes peuvent se rompre à tout moment car il n'y a plus de contrat entre le serveur et le client. Le serveur a maintenant retiré le contrôle au client.

À travers cet article, je rejoins la conversation. Je répondrai à la critique de Jason et, ce faisant, je décrirai l'approche de mon plugin, et montrerai pourquoi je crois qu'elle peut en réalité très bien s'adapter à Gutenberg.

Utiliser COPE pour extraire les métadonnées de Gutenberg

Ma solution pourrait être considérée comme la 4e approche, et elle est la suivante :

Pour obtenir les données Gutenberg qui alimenteront GraphQL, ne pas créer un schéma supplémentaire côté PHP, ni dupliquer des données existantes. À la place, extraire les données du contenu stocké des blocs, en utilisant la stratégie COPE ("Create Once, Publish Everywhere").

(COPE est une stratégie qui permet d'avoir une seule source de vérité du contenu, et de l'exposer à différentes applications. Dans notre cas, la seule source de vérité est les données des blocs Gutenberg, telles qu'elles sont stockées dans la base de données. J'ai décrit COPE et son implémentation pour WordPress dans cet article.)

Enfin, nous pouvons utiliser GraphQL pour récupérer les données extraites, pour n'importe quel bloc Gutenberg, en mappant tous les blocs sur un seul type Block.

Cette stratégie est un compromis, pas une solution définitive

Cette stratégie ne résout pas le problème que Jason soulève : l'absence d'un schéma côté serveur, qui permettrait la création d'un contrat entre le serveur et le client.

COPE ne peut pas résoudre ce problème car, uniquement depuis le contenu stocké, nous ne pouvons pas recréer le schéma :

  • Le contenu stocké n'indique pas le type du champ
  • Le contenu stocké n'indique pas quelles restrictions le champ possède (est-il nullable ? est-ce un entier positif ? la chaîne est-elle pour un e-mail ou une URL ?)
  • Les champs nullable peuvent avoir une valeur par défaut, qui ne sera pas présente dans le contenu stocké

Cependant, en utilisant la stratégie COPE et un seul type Block pour représenter tous les blocs, Gato GraphQL peut construire une intégration très correcte avec Gutenberg, qui surmonte les limitations existantes.

Je l'expliquerai tout au long de cet article.

L'intégration de Gato GraphQL avec Gutenberg

Cette solution est un travail en cours, mais je peux déjà expliquer comment elle se comportera.

Au lieu de dépendre d'un type différent par bloc (comme WPGraphQL le fait en s'appuyant sur le plugin WPGraphQL for Gutenberg), Gato GraphQL fournira un seul type Block pour représenter tous les blocs.

Dans cette requête, le champ Post.blockDataItems récupère une liste d'éléments Block depuis l'article (pour différents blocs Gutenberg, incluant des paragraphes, des images, des listes et d'autres) :

{
  post(by: { id: 1499 }) {
    title
    blockDataItems
  }
}

Si nous voulons récupérer des données pour un bloc spécifique, nous pouvons filtrer par le nom du bloc (core/paragraph, core/quote, etc).

Dans cette requête, nous récupérons uniquement les blocs d'image :

{
  post(by: { id: 1177 }) {
    title
    blockDataItems(
      filterBy: { include: "core/image" }
    )
  }
}

Inspection du type unique Block

Avec cette approche, la réponse peut varier selon le contenu stocké, pas selon un schéma. Cette qualité est à la fois son avantage (puisqu'elle rend l'API flexible) et son inconvénient (nous ne pouvons pas imposer de contrats serveur-client).

Chaque élément Block contient deux propriétés :

  • name : Le nom du bloc (core/paragraph, core/quote, etc)
  • meta : Les métadonnées contenues dans le bloc

Chaque bloc Gutenberg est différent, contenant des données différentes (un contenu de paragraphe, une vidéo YouTube, une URL source d'image et ses dimensions, etc). Par conséquent, les données contenues dans la réponse pour le champ meta seront également différentes.

À ce titre, le champ meta a simplement été mappé comme un objet JSON (qui peut contenir des données "brutes"), via un type JSONObject correspondant dans le schéma GraphQL.

Il produit cette réponse :

{
  "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"
            ]
          }
        },
      ]
    }
  }
}

Comme nous pouvons le voir, nous avons différents blocs récupérant différentes propriétés :

  • core/paragraph possède la propriété content
  • core/image possède la propriété src, et optionnellement les propriétés width, height et caption (n'apparaissant pas dans la réponse ci-dessus)
  • core/quote possède les propriétés quote et cite (pour la personne citée)
  • core/heading possède les propriétés header et size (la valeur xl représente <h2>, car COPE découple la valeur de l'application cible, dans ce cas un site web)
  • core/list possède la propriété items, qui est une liste d'éléments

Pourquoi le type JSONObject ne fait pas partie de la spec

Le type JSONObject que j'ai décrit ci-dessus permet à GraphQL de récupérer des champs "dynamiques" (tels que des champs que nous ne connaissons pas), ou des champs qui peuvent avoir plusieurs configurations (comme c'est le cas avec les blocs Gutenberg).

Or, la spec GraphQL ne supporte pas actuellement les types JSONObject ou Map. Un support a été demandé, pour des raisons telles que :

[...] l'absence de cette fonctionnalité est particulièrement problématique car elle est supportée dans de nombreux systèmes de types et services avec lesquels GraphQL s'interface.

Cela conduit à implémenter des resolvers personnalisés sur le serveur, suivis de transformations personnalisées sur le client, pour gérer des situations où mon serveur envoie une Map, et mon client veut une Map, et GraphQL est au milieu sans support des Maps. Oui, c'est possible, et je l'ai fait, mais c'est un peu trop de boilerplate et d'abstraction qui semble annuler le but d'écrire la spec de l'API en GraphQL.

Cette fonctionnalité n'est pas supportée par la spec car gérer des champs dynamiques va à l'encontre du comportement de typage fort de GraphQL, qui rompt le contrat entre le serveur et le client.

Néanmoins, ce type peut être bénéfique à Gutenberg, comme je le montrerai plus loin.

Problèmes lors de l'utilisation d'un type différent par bloc, et d'un registre côté serveur

Si on crée un nouveau type GraphQL par bloc, alors tous les plugins doivent avoir leurs blocs ajoutés au schéma GraphQL. Cela pourrait être accompli automatiquement en faisant en sorte que tous les blocs définissent leurs propriétés dans le nouveau registre côté serveur proposé.

S'ils ne le font pas, leurs blocs seront indisponibles pour l'API, et cela peut avoir des conséquences supplémentaires. Dans certaines circonstances, tout le contenu de l'article interrogé peut devenir peu fiable.

Ce peut être le cas lorsque GraphQL interagit avec un service externe basé dans le cloud, qui applique une fonction à tous les blocs de l'article (pensez à la traduction, à la correction grammaticale, aux suggestions SEO, aux analyses, etc).

Voyons un exemple de cela.

Puisque les capacités multilingues seront ajoutées à Gutenberg en phase 4, modélisons comment traduire tous les blocs du plugin, via un appel à l'API Google Translate exécuté via une directive @strTranslate.

(Après cette traduction initiale basée sur l'API, l'utilisateur peut continuer à modifier l'article de blog, dans la langue traduite, toujours dans l'éditeur WordPress.)

Différents blocs contiennent différentes informations qui doivent être traduites :

  • core/paragraph : le texte
  • core/image : la légende
  • core/quote : la citation, et la personne citée (car ce pourrait être le titre de la personne, tel que "The school headmaster")
  • core/heading : l'en-tête
  • core/list : tous les éléments de la liste

En utilisant un type différent par bloc, la requête résultante pourrait ressembler à ceci :

{
  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
      }
    }
  }
}

Et ainsi de suite. Plus nous avons de blocs, plus cette requête sera longue, pouvant facilement s'étendre sur une centaine de lignes et plus encore.

Le problème évident est que la requête devient une bête sauvage que nous devons maintenir.

De plus, nous devons introduire des fonctionnalités personnalisées pour qu'elle fonctionne avec chaque bloc. Par exemple, @strTranslate ne fonctionne pas avec CoreListBlock.items, qui retourne une liste de chaînes (c'est-à-dire qu'il retourne [String], alors que la directive attend String), et donc nous devons créer @strTranslateList.

Et alors core/table nécessiterait sa propre directive personnalisée (@strTranslateTable ?).

Et les blocs tiers personnalisés pourraient avoir besoin de leurs propres directives personnalisées.

Et ensuite, je vois quelques problèmes supplémentaires.

C'est tout ou rien

Un article de blog peut contenir n'importe quel bloc installé dans l'éditeur WordPress. Et nous ne savons pas à l'avance (lors du codage de la requête) quels blocs l'article utilise.

Alors, avec un type par bloc, le nombre de types à gérer dans la requête ne sera pas équivalent au nombre de blocs dans l'article. Il sera plutôt équivalent au nombre de blocs installés dans l'éditeur WordPress.

Que se passe-t-il si nous avons 100 blocs sur notre site, incluant à la fois ceux du cœur de WordPress et des plugins ? Alors nous devons avoir 100 types mappés sur le schéma GraphQL. Un seul qui n'est pas mappé peut rompre le "contrat de contenu", entraînant que certains blocs soient traduits de l'anglais au français, tandis que d'autres restent en anglais.

En conséquence, nous ne pourrons plus faire confiance aux articles traduits, qu'ils contiennent ou non le bloc problématique. Donc si tous les blocs ne sont pas ajoutés au registre, l'application peut devenir peu fiable.

La requête doit être mise à jour à chaque fois qu'un nouveau bloc est installé

De même, chaque bloc doit être géré dans la requête GraphQL. Cela signifie que, chaque fois qu'un nouveau bloc est installé, nous devons aller dans le code de notre application, le mettre à jour, et le redéployer.

Ce n'est pas seulement de la bureaucratie supplémentaire : nous ne pourrons pas installer un bloc sur un site en production, sans craindre de rompre l'application (jusqu'à ce que toutes les requêtes soient mises à jour).

GraphQL doit servir WordPress, pas l'inverse

En considérant à nouveau pourquoi JSONObject n'a pas été ajouté à la spec GraphQL, c'est parce que cela ne correspond pas à la façon dont GraphQL fonctionne.

Cependant, ici nous ne nous préoccupons pas vraiment de GraphQL. Nous nous préoccupons uniquement de WordPress et, plus spécifiquement dans ce cas, de Gutenberg.

Lors de l'intégration de GraphQL avec Gutenberg, GraphQL opérera dans le contexte de WordPress. Cela signifie que WordPress devra satisfaire les exigences de GraphQL. Mais plus important encore, c'est GraphQL qui doit satisfaire les exigences de WordPress.

Et en cas de conflit, WordPress a la priorité.

Si une fonctionnalité ne convient pas à GraphQL, mais qu'elle convient néanmoins à Gutenberg, doit-elle être considérée ?

Je pense que oui.

Voyons comment un seul type Block peut mieux servir Gutenberg.

Résoudre les problèmes précédents via un seul type Block

En suivant l'exemple précédent, traduire tous les blocs d'un article de l'anglais au français, en utilisant un seul type Block, se fera ainsi (ou quelque chose dans ce sens) :

{
  post(by: { id: 1 }) {
    blocks {
      name
      meta
        @advancePointersInArray(paths: "{{ translatablePaths }}")
          @underEachArrayItem
            @strTranslate(from: "en", to: "fr")
    }
  }
}

C'est tout ? Toute la requête ? Pour traduire tous les blocs ? Oui.

Fonctionnera-t-elle pour tous les blocs, du cœur comme des plugins, déjà existants ou à créer ? Oui.

Cette requête vous semble-t-elle un peu étrange ? Si oui, c'est parce qu'elle utilise des fonctionnalités GraphQL non standard, supportées uniquement par Gato GraphQL :

  • {{ translatablePaths }} est un champ intégrable, pour injecter la valeur d'un champ comme argument d'un autre champ ou directive (dans ce cas, le type Block aura un champ translatableFields, dont la valeur est injectée dans la directive @advancePointersInArray)
  • les directives peuvent être composées d'autres directives

Maintenant, si une fonctionnalité satisfait exactement ce dont le CMS a besoin, mais que la fonctionnalité est non standard, devrions-nous quand même l'utiliser ? Je pense que oui.

J'ai également demandé ces fonctionnalités pour la spec GraphQL (même si elles ne seront pas acceptées) :

Comment fonctionne le type unique Block

Avertissement : section technique à venir.

Le type Block aura un champ translatablePaths, retournant un tableau des propriétés du JSONObject qui doivent être traduites :

  • core/paragraph retourne ["content"]
  • core/image retourne ["caption"]
  • core/quote retourne ["quote", "cite"]
  • core/heading retourne ["header"]
  • core/list retourne ["items.0", "items.1", "items.2", ...]

@advancePointersInArray est une méta-directive : elle modifie le contexte pour une directive suivante. Elle fait en sorte que la directive suivante reçoive un sous-élément depuis le JSONObject interrogé, tel que la propriété content du bloc paragraphe. La liste des chemins est obtenue via le champ translatablePaths, évalué sur la même entité interrogée.

Ensuite, @underEachArrayItem est une autre méta-directive, qui itère sur une liste d'éléments de l'entité interrogée, et passe une référence à l'élément itéré à la directive suivante. Dans ce cas, elle obtient toute la liste des propriétés à traduire pour toutes les entités, chacune de type String, et passe des éléments String individuels plus loin.

Enfin, la directive @strTranslate reçoit un élément de type String contenu dans le JSONObject, et le traduit directement, à l'intérieur du JSONObject lui-même.

Veuillez noter à quel point cette solution est flexible. Il suffit de fournir le chemin vers la chaîne dans le JSONObject pour accéder à la valeur, la modifier avec @strTranslate (ou toute autre directive), et éventuellement même stocker la valeur à nouveau dans la base de données (le travail pour accomplir cela est actuellement en cours).

Cela fonctionne déjà pour core/list, car tous les éléments de la liste peuvent être atteints sous leur propre chemin (items.0 est le 1er élément du tableau, etc). Ensuite, on peut accéder à la valeur String de chacun, et la passer à @strTranslate, donc il n'y a pas besoin de créer @strTranslateList.

De même, cela fonctionnera également avec core/table. Nous avons juste besoin d'exposer les données via la propriété cells, qui sera un tableau à 2 dimensions (une pour les lignes, contenant une pour les colonnes). Ensuite, translatablePaths peut atteindre tous les éléments comme ["cells.0.0", "cells.0.1", "cells.1.0", ...].

Et cela fonctionnera pour tout bloc tiers également. Pour cela, nous devons faire attention à la façon dont les données du bloc sont stockées, et à partir de là nous pouvons déduire le chemin vers ses propriétés.

Un seul Block nécessite une configuration, basée sur du code PHP

Mapper les blocs, pour savoir où trouver leurs propriétés de métadonnées, peut être accompli via la configuration. Nous pouvons donc le gérer d'une façon très flexible.

Dans Gutenberg, il y a deux endroits où une propriété d'un bloc peut être stockée : comme attribut, ou à l'intérieur du contenu rendu.

Par exemple, voici comment le bloc core/image est stocké :

<!-- 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 -->

Dans ce cas, nous avons :

  1. Les propriétés id, sizeSlug et linkDestination sont stockées comme attributs
  2. La propriété src est stockée à l'intérieur du contenu rendu

Maintenant, lors de l'interrogation de l'API, la réponse pour le bloc core/image sera la suivante :

{
  "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 sait comment récupérer les propriétés en analysant le bloc stocké dans Gutenberg (c'est la stratégie COPE). Ce processus peut être effectué automatiquement jusqu'à un certain degré, puis quelques entrées manuelles via des hooks, ou via une interface utilisateur.

Obtenir les propriétés directement mappées comme attributs est trivial. Le serveur GraphQL peut déjà récupérer tous les attributs du bloc, et les rendre disponibles comme propriétés. Ou, si nous voulons définir explicitement lesquelles exposer, nous pouvons le faire via des filter hooks :

$attrs = apply_filters("blockPropsAsAttr:core/image", []);
 
add_filter("blockPropsAsAttr:core/image", function ($attrs) {
  return array_merge($attrs, ['id', 'sizeSlug', 'linkDestination']);
})

Les propriétés stockées dans le contenu peuvent être extraites via une regex :

$propRegexes = apply_filters("blockPropsAsRegex:core/image", []);
 
add_filter("blockPropsAsRegex:core/image", function ($propRegexes) {
  $propRegexes['src'] = '/<img src="(.*?)"/';
  return $propRegexes;
})

Enfin, nous indiquons quelles sont les propriétés traduisibles du bloc, pour que @strTranslate agisse dessus :

$propRegexes = apply_filters("translatableProperties:core/image", []);
 
add_filter("translatableProperties:core/image", function ($properties) {
  $properties[] = 'caption';
  return $properties;
})

Maintenant, ces propriétés doivent encore être satisfaites par quelqu'un, vraisemblablement le développeur du plugin. D'où l'intérêt d'avoir le registre côté serveur pour atteindre cet objectif.

Mais si la communauté WordPress ne veut pas ajouter le registre côté serveur proposé ? Eh bien, cette stratégie peut s'adapter facilement, car le mapping peut être fait via du code PHP, comme vient d'être montré.

Si un bloc n'a pas été mappé, l'utilisateur peut également le faire, en sachant juste un peu de Gutenberg, et rien sur GraphQL ou les schémas.

De plus, nous pouvons faire en sorte que GraphQL alerte l'utilisateur quand il y a un bloc qui n'a pas été mappé (et donc ne peut pas être traduit). Nous pouvons faire cela en ajoutant une méta-directive @if qui, si la condition s'applique, exécute la directive @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"
          )
    }
  }
}

Cette solution est flexible et simple, et a GraphQL servant WordPress, sans exiger des développeurs qu'ils apprennent une nouvelle technologie, ni modifier le fonctionnement de Gutenberg.

Conclusion

Lorsqu'on réfléchit à quoi pourrait ressembler une intégration possible entre GraphQL et Gutenberg (dans le cadre d'une éventuelle inclusion dans le cœur de WordPress), nous devons nous assurer que GraphQL peut gérer toutes les futures exigences de Gutenberg, notamment un support complet pour :

  • les blocs multilingues
  • le Full Site Editing
  • l'édition collaborative
  • l'interaction avec des services tiers sur un site en production

Tout cela doit être accompli idéalement sans avoir besoin de modifier Gutenberg (du moins, pas de manière considérable), et en réduisant les nouvelles tâches requises des développeurs de plugins.

En tenant compte de cela, je crois que la 4e approche que je suggère ici peut en effet très bien fonctionner.


Abonnez-vous à notre newsletter

Restez au courant de toutes les nouveautés de Gato GraphQL.