💬 Een nieuw aanpak voorstellen voor 'Gutenberg en Ontkoppelde Applicaties'
Een paar dagen geleden publiceerde WPGraphQL's bedenker Jason Bahl Gutenberg and Decoupled Applications, waarin hij de voordelen en tekortkomingen van 3 benaderingen voor de integratie van GraphQL met Gutenberg analyseert.
Een week eerder had hij ook op Twitter gezegd dat de aanpak van Gato GraphQL voor het modelleren van Gutenberg ongeschikt is:
Dit is naar mijn mening niets om trots op te zijn. Eén ding dat GraphQL probeert op te lossen met een getypeerd schema is het bieden van voorspelbaarheid en consistentie voor clients, en clients de controle geven om te vragen wat ze willen, tot op het niveau van het veld.
Het retourneren van een generiek "Object"-type zonder voorspelbare vorm betekent dat clientapplicaties op elk moment kunnen breken, omdat er geen contract meer bestaat tussen de server en de client. De server heeft nu de controle van de client overgenomen.
Via dit artikel sluit ik me aan bij het gesprek. Ik ga in op de kritiek van Jason en beschrijf daarbij de aanpak van mijn plugin, en laat zien waarom ik denk dat die heel goed bij Gutenberg kan passen.
COPE gebruiken om Gutenberg-metadata te extraheren
Mijn oplossing kan worden beschouwd als de 4e aanpak, en die ziet er als volgt uit:
Om de Gutenberg-data te verkrijgen waarmee GraphQL wordt aangestuurd, maak je geen extra schema aan aan de PHP-kant en dupliceer je geen bestaande data. Extraheer in plaats daarvan de data uit de opgeslagen inhoud van de blokken, met behulp van de COPE-strategie ("Create Once, Publish Everywhere").
(COPE is een strategie die het mogelijk maakt één enkel bronpunt voor inhoud te hebben en die beschikbaar te stellen aan verschillende applicaties. In ons geval is het enige bronpunt de Gutenberg-blokdata, zoals opgeslagen in de database. Ik heb COPE en de implementatie ervan voor WordPress beschreven in dit artikel.)
Ten slotte kunnen we GraphQL gebruiken om de geëxtraheerde data op te halen voor elk Gutenberg-blok, door alle blokken toe te wijzen aan één enkel Block-type.
Deze strategie is een afweging, geen definitieve oplossing
Deze strategie lost het probleem niet op dat Jason aanwijst: het ontbreken van een schema aan de serverzijde, wat het mogelijk zou maken een contract te creëren tussen de server en de client.
COPE kan dit probleem niet oplossen omdat we het schema uitsluitend op basis van de opgeslagen inhoud niet kunnen reconstrueren:
- De opgeslagen inhoud geeft het type van het veld niet aan
- De opgeslagen inhoud geeft niet aan welke beperkingen het veld heeft (is het nullable? is het een positief geheel getal? is de string voor een e-mailadres of een URL?)
- Nullable velden kunnen een standaardwaarde hebben, die niet aanwezig zal zijn in de opgeslagen inhoud
Met behulp van de COPE-strategie en één enkel Block-type om alle blokken te vertegenwoordigen, kan Gato GraphQL echter een zeer goede integratie met Gutenberg bouwen die de bestaande beperkingen overwint.
Ik zal dit gedurende dit artikel toelichten.
De integratie van Gato GraphQL met Gutenberg
Deze oplossing is in ontwikkeling, maar ik kan al uitleggen hoe die zich zal gedragen.
In plaats van te vertrouwen op een ander type per blok (zoals WPGraphQL doet bij gebruik van de plugin WPGraphQL for Gutenberg), biedt Gato GraphQL één enkel Block-type om alle blokken te vertegenwoordigen.
In deze query haalt het veld Post.blockDataItems een lijst van Block-elementen op uit de post (voor verschillende Gutenberg-blokken, waaronder alinea's, afbeeldingen, lijsten en andere):
{
post(by: { id: 1499 }) {
title
blockDataItems
}
}Als we data voor een specifiek blok willen ophalen, kunnen we filteren op de naam van het blok (core/paragraph, core/quote, enz.).
In deze query halen we alleen de afbeeldingsblokken op:
{
post(by: { id: 1177 }) {
title
blockDataItems(
filterBy: { include: "core/image" }
)
}
}Het enkele Block-type inspecteren
Met deze aanpak kan de respons variëren afhankelijk van de opgeslagen inhoud, niet van een schema. Deze eigenschap is zowel het voordeel (omdat het de API flexibel maakt) als het nadeel (we kunnen geen server-client-contracten afdwingen).
Elk Block-element bevat twee eigenschappen:
name: De naam van het blok (core/paragraph,core/quote, enz.)meta: De metadata in het blok
Elk Gutenberg-blok is anders en bevat verschillende data (een alinea-inhoud, een Youtube-video, een URL van een afbeeldingsbron met afmetingen, enz.). Daardoor zal de data in de respons voor het veld meta ook verschillen.
Daarom is het veld meta simpelweg toegewezen als een JSON-object (dat "ruwe" data kan bevatten), via een overeenkomstig JSONObject-type in het GraphQL-schema.
Dit levert de volgende respons op:
{
"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"
]
}
},
]
}
}
}Zoals we kunnen zien, halen verschillende blokken verschillende eigenschappen op:
core/paragraphheeft eigenschapcontentcore/imageheeft eigenschapsrc, en optioneel eigenschappenwidth,heightencaption(niet aanwezig in de bovenstaande respons)core/quoteheeft eigenschappenquoteencite(voor de geciteerde persoon)core/headingheeft eigenschappenheaderensize(waardexlstaat voor<h2>, omdat COPE de waarde loskoppelt van de doeltoepassing, in dit geval een website)core/listheeft eigenschapitems, wat een lijst van elementen is
Waarom het JSONObject-type geen deel uitmaakt van de specificatie
Het JSONObject-type dat ik hierboven beschreef, stelt GraphQL in staat "dynamische" velden op te halen (zoals velden die we niet kennen), of velden die meerdere configuraties kunnen hebben (zoals het geval kan zijn bij Gutenberg-blokken).
De GraphQL-specificatie ondersteunt momenteel de typen JSONObject of Map niet. Ondersteuning toevoegen is aangevraagd, om redenen zoals:
[...] het ontbreken van deze functie is bijzonder problematisch omdat die wordt ondersteund in veel van de typesystemen en services waarmee GraphQL samenwerkt.
Dit leidt tot het implementeren van aangepaste resolvers op de server, gevolgd door aangepaste transformaties aan de clientzijde, om situaties aan te pakken waarbij mijn server een Map verstuurt, mijn client een Map wil, en GraphQL er tussenin zit zonder ondersteuning voor Maps. Ja, het is mogelijk en ik heb het gedaan, maar het vergt nogal wat boilerplate en abstractie wat het doel lijkt te ondermijnen van de API-specificatie in GraphQL schrijven.
Deze functie wordt niet ondersteund door de specificatie omdat het omgaan met dynamische velden ingaat tegen het sterke typegedrag van GraphQL, wat het contract tussen de server en de client verbreekt.
Toch kan dit type nuttig zijn voor Gutenberg, zoals ik later zal laten zien.
Problemen bij het gebruik van een ander type per blok en een server-side registry
Als we voor elk blok een nieuw GraphQL-type aanmaken, moeten alle plugins hun blokken aan het GraphQL-schema toevoegen. Dit kan automatisch worden bereikt door alle blokken hun eigenschappen te laten definiëren in de voorgestelde nieuwe server-side registry.
Als ze dat niet doen, zijn hun blokken niet beschikbaar voor de API, wat extra gevolgen kan hebben. In sommige omstandigheden kan de volledige inhoud van de opgevraagde post onbetrouwbaar worden.
Dit kan het geval zijn wanneer GraphQL samenwerkt met een externe cloudgebaseerde service die een functie toepast op alle blokken in de post (denk aan vertaling, grammaticacontrole, SEO-suggesties, analytics, enz.).
Laten we een voorbeeld bekijken.
Omdat meertalige mogelijkheden in fase 4 aan Gutenberg worden toegevoegd, laten we modelleren hoe alle blokken in de plugin vertaald worden via een aanroep van de Google Translate API, uitgevoerd via een @strTranslate-directive.
(Na deze initiële API-gebaseerde vertaling kan de gebruiker de blogpost blijven bewerken in de vertaalde taal, altijd binnen de WordPress-editor.)
Verschillende blokken bevatten verschillende stukken informatie die vertaald moeten worden:
core/paragraph: de tekstcore/image: het bijschriftcore/quote: het citaat en de geciteerde persoon (omdat het de titel van de persoon kan zijn, zoals "The school headmaster")core/heading: de kopcore/list: alle items in de lijst
Met een ander type per blok kan de resulterende query er ongeveer zo uitzien:
{
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
}
}
}
}En zo verder. Hoe meer blokken we hebben, hoe langer deze query wordt, en die kan gemakkelijk honderd regels of meer beslaan.
Het voor de hand liggende probleem is dat de query een wild beest wordt dat we moeten onderhouden.
Bovendien moeten we voor elk blok aangepaste functionaliteit introduceren om het werkend te maken. Zo werkt @strTranslate niet met CoreListBlock.items, dat een lijst van strings retourneert (d.w.z. het retourneert [String], terwijl de directive String verwacht), en dus moeten we @strTranslateList aanmaken.
En dan zou core/table zijn eigen aangepaste directive nodig hebben (@strTranslateTable?).
En aangepaste blokken van derden kunnen hun eigen aangepaste directives nodig hebben.
En dan zie ik nog een paar extra problemen.
Alles of niets
Een blogpost kan elk blok bevatten dat in de WordPress-editor is geïnstalleerd. En we weten van tevoren niet (bij het schrijven van de query) welke blokken de post gebruikt.
Bij één type per blok zal het aantal te verwerken typen in de query niet gelijkwaardig zijn aan het aantal blokken in de post. In plaats daarvan zal het gelijkwaardig zijn aan het aantal blokken dat in de WordPress-editor is geïnstalleerd.
Wat gebeurt er als we 100 blokken op onze site hebben, zowel van WordPress core als van plugins? Dan moeten we 100 typen hebben die zijn toegewezen aan het GraphQL-schema. Eén enkel niet-toegewezen type kan het "inhoudscontract" verbreken, waardoor sommige blokken van Engels naar Frans worden vertaald, terwijl andere in het Engels blijven.
Als gevolg hiervan kunnen we de vertaalde posts niet meer vertrouwen, ongeacht of ze het problematische blok bevatten. Als dus niet alle blokken aan de registry worden toegevoegd, kan de applicatie onbetrouwbaar worden.
De query moet worden bijgewerkt telkens wanneer een nieuw blok wordt geïnstalleerd
Evenzo moet elk blok worden afgehandeld in de GraphQL-query. Dat betekent dat we telkens wanneer we een nieuw blok installeren, naar de code van onze applicatie moeten gaan, die moeten bijwerken en opnieuw moeten deployen.
Dit is niet alleen extra bureaucratie: we kunnen geen blok installeren op een live site zonder de angst de applicatie te breken (totdat alle queries zijn bijgewerkt).
GraphQL moet WordPress dienen, niet andersom
Als we opnieuw bedenken waarom JSONObject niet aan de GraphQL-specificatie is toegevoegd, is dat omdat het niet past bij de manier waarop GraphQL werkt.
Hier zijn we echter niet echt bezig met GraphQL. We geven alleen om WordPress en, specifieker in dit geval, Gutenberg.
Bij de integratie van GraphQL met Gutenberg zal GraphQL opereren binnen de context van WordPress. Dat betekent dat WordPress moet voldoen aan de vereisten van GraphQL. Maar wat belangrijker is, is dat GraphQL moet voldoen aan de vereisten van WordPress.
En in geval van conflict, heeft WordPress prioriteit.
Als een functie niet bij GraphQL past, maar wel bij Gutenberg, moet die dan worden overwogen?
Ik denk van wel.
Laten we kijken hoe één enkel Block-type Gutenberg beter kan dienen.
De vorige problemen oplossen via één enkel Block-type
Voortbordurend op het vorige voorbeeld, zal het vertalen van alle blokken in een post van Engels naar Frans, met behulp van één enkel Block-type, er als volgt uitzien (of iets in de trant van dit concept):
{
post(by: { id: 1 }) {
blocks {
name
meta
@advancePointersInArray(paths: "{{ translatablePaths }}")
@underEachArrayItem
@strTranslate(from: "en", to: "fr")
}
}
}Dat is alles? De hele query? Om alle blokken te vertalen? Ja.
Werkt het voor alle blokken, zowel van core als van plugins, al bestaande of nog te maken? Ja.
Ziet deze query er een beetje vreemd uit voor je? Als dat zo is, komt dat doordat die niet-standaard GraphQL-functies gebruikt die alleen door Gato GraphQL worden ondersteund:
{{ translatablePaths }}is een ingebouwd veld, om de waarde van een veld als argument door te geven aan een ander veld of directive (in dit geval zal hetBlock-type een veldtranslatableFieldshebben, waarvan de waarde wordt ingespoten in directive@advancePointersInArray)- directives kunnen worden samengesteld uit andere directives
Als een functie precies voldoet aan wat het CMS nodig heeft, maar de functie is niet-standaard, moeten we die dan toch gebruiken? Ik denk van wel.
Ik heb deze functies ook aangevraagd voor de GraphQL-specificatie (ook al zullen ze niet worden geaccepteerd):
Hoe het enkele Block-type werkt
Waarschuwing: technisch gedeelte komt eraan.
Het Block-type zal een veld translatablePaths hebben dat een array retourneert van de eigenschappen uit het JSONObject die vertaald moeten worden:
core/paragraphretourneert["content"]core/imageretourneert["caption"]core/quoteretourneert["quote", "cite"]core/headingretourneert["header"]core/listretourneert["items.0", "items.1", "items.2", ...]
@advancePointersInArray is een meta-directive: het wijzigt de context voor een volgende directive. Het zorgt ervoor dat de volgende directive een sub-element ontvangt uit het opgevraagde JSONObject, zoals de eigenschap content uit het alinea-blok. De lijst van paden wordt verkregen via het veld translatablePaths, geëvalueerd op dezelfde opgevraagde entiteit.
Vervolgens is @underEachArrayItem een andere meta-directive die itereert over een lijst van elementen van de opgevraagde entiteit en een verwijzing naar het geïtereerde element doorgeeft aan de volgende directive. In dit geval haalt het de volledige lijst op van de te vertalen eigenschappen voor alle entiteiten, elk van het type String, en geeft individuele String-elementen door.
Ten slotte ontvangt directive @strTranslate een element van het type String dat zich in het JSONObject bevindt, en vertaalt het daar ter plekke, binnen het JSONObject zelf.
Let op hoe flexibel deze oplossing is. Alleen het opgeven van het pad naar de string binnen het JSONObject is voldoende om toegang te krijgen tot de waarde, die te wijzigen met @strTranslate (of een andere directive), en mogelijk zelfs de waarde opnieuw op te slaan in de database (werk hieraan is momenteel in uitvoering).
Het werkt al voor core/list, omdat alle elementen in de lijst bereikbaar zijn via hun eigen pad (items.0 is het 1e element in de array, enzovoort). Vervolgens kan het de String-waarde van elk element ophalen en doorgeven aan @strTranslate, zodat het niet nodig is om @strTranslateList aan te maken.
Op dezelfde manier werkt het ook met core/table. We hoeven alleen de data beschikbaar te stellen via eigenschap cells, wat een 2-dimensionaal array zal zijn (één voor rijen, met daarin één voor kolommen). Vervolgens kan translatablePaths alle elementen bereiken als ["cells.0.0", "cells.0.1", "cells.1.0", ...].
En het zal ook werken voor elk blok van derden. Daarvoor moeten we letten op hoe de blokdata is opgeslagen, en van daaruit kunnen we het pad naar de eigenschappen afleiden.
Een enkel Block vereist configuratie, gebaseerd op PHP-code
Het toewijzen van de blokken, zodat we weten waar we hun metadata-eigenschappen kunnen vinden, kan worden bereikt via configuratie. We kunnen er dus op een zeer flexibele manier mee omgaan.
In Gutenberg zijn er twee plaatsen waar een eigenschap van een blok kan worden opgeslagen: als attribuut of in de gerenderde inhoud.
Dit is bijvoorbeeld hoe het core/image-blok wordt opgeslagen:
<!-- 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 dit geval hebben we:
- Eigenschappen
id,sizeSlugenlinkDestinationworden opgeslagen als attributen - Eigenschap
srcwordt opgeslagen in de gerenderde inhoud
Wanneer we de API bevragen, zal de respons voor het core/image-blok als volgt zijn:
{
"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"
}
}
]
}
}De API weet hoe eigenschappen opgehaald moeten worden door het opgeslagen blok in Gutenberg te verwerken (dat is de COPE-strategie). Dit proces kan tot op zekere hoogte automatisch worden gedaan, en daarna is er enige handmatige invoer nodig via hooks of via een gebruikersinterface.
Het direct ophalen van eigenschappen die als attributen zijn toegewezen is triviaal. De GraphQL-server kan al alle attributen van het blok ophalen en beschikbaar stellen als eigenschappen. Of, als we expliciet willen definiëren welke we willen blootstellen, kunnen we dat doen via filter hooks:
$attrs = apply_filters("blockPropsAsAttr:core/image", []);
add_filter("blockPropsAsAttr:core/image", function ($attrs) {
return array_merge($attrs, ['id', 'sizeSlug', 'linkDestination']);
})De eigenschappen die in de inhoud zijn opgeslagen kunnen worden geëxtraheerd via een regex:
$propRegexes = apply_filters("blockPropsAsRegex:core/image", []);
add_filter("blockPropsAsRegex:core/image", function ($propRegexes) {
$propRegexes['src'] = '/<img src="(.*?)"/';
return $propRegexes;
})Ten slotte geven we aan welke eigenschappen van het blok vertaalbaar zijn, zodat @strTranslate daarop kan inwerken:
$propRegexes = apply_filters("translatableProperties:core/image", []);
add_filter("translatableProperties:core/image", function ($properties) {
$properties[] = 'caption';
return $properties;
})Deze eigenschappen moeten nog steeds door iemand worden ingevuld, hoogstwaarschijnlijk door de plugin-ontwikkelaar. Daarom zal het hebben van de server-side registry helpen dit doel te bereiken.
Maar wat als de WordPress-gemeenschap de voorgestelde server-side registry niet wil toevoegen? Welnu, deze strategie kan zich gemakkelijk aanpassen, omdat de toewijzing kan worden gedaan via PHP-code, zoals zojuist getoond.
Als een blok niet is toegewezen, kan de gebruiker dit ook zelf doen, met slechts een beetje kennis van Gutenberg en zonder iets te hoeven weten over GraphQL of schema's.
Bovendien kunnen we GraphQL de gebruiker laten waarschuwen wanneer er een blok is dat niet is toegewezen (en dus niet vertaald kan worden). We kunnen dit doen door een meta-directive @if toe te voegen die, als de voorwaarde van toepassing is, de directive @sendEmail uitvoert:
{
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"
)
}
}
}Deze oplossing is flexibel en eenvoudig, en zorgt ervoor dat GraphQL WordPress dient, zonder dat ontwikkelaars een nieuwe technologie hoeven te leren of de manier waarop Gutenberg werkt hoeven te veranderen.
Conclusie
Wanneer we nadenken over hoe een mogelijke integratie tussen GraphQL en Gutenberg eruit zal zien (vanuit een mogelijke opname in WordPress core), moeten we ervoor zorgen dat GraphQL alle toekomstige vereisten van Gutenberg aankan, inclusief volledige ondersteuning voor:
- meertalige blokken
- Full Site Editing
- gezamenlijk bewerken
- interactie met diensten van derden op een live site
Dit alles moet bij voorkeur worden bereikt zonder Gutenberg te hoeven wijzigen (althans niet op een aanzienlijke manier), en door de nieuwe taken die van plugin-ontwikkelaars worden vereist te verminderen.
Dit alles in aanmerking genomen, geloof ik dat de 4e aanpak die ik hier voorstel inderdaad heel goed kan werken.