Blog

๐Ÿ’ฌ Mengusulkan pendekatan baru untuk 'Gutenberg dan Aplikasi Terpisah'

Leonardo Losoviz
Oleh Leonardo Losoviz ยท

Beberapa hari yang lalu, Jason Bahl, pencipta WPGraphQL, menerbitkan Gutenberg and Decoupled Applications, yang menganalisis manfaat dan kekurangan dari 3 pendekatan untuk mengintegrasikan GraphQL dengan Gutenberg.

Seminggu sebelumnya, ia juga telah berkata di Twitter bahwa pendekatan Gato GraphQL dalam memodelkan Gutenberg tidak tepat:

Ini bukan sesuatu yang patut dibanggakan, menurut saya. Salah satu hal yang GraphQL coba selesaikan dengan Typed Schema adalah menyediakan keterprediksan dan konsistensi bagi klien, serta memberikan klien kendali untuk meminta apa yang mereka inginkan, hingga ke level field.

Mengembalikan tipe "Object" wildcard tanpa bentuk yang dapat diprediksi berarti aplikasi klien dapat rusak kapan saja karena tidak ada lagi kontrak antara server dan klien. Server kini telah mengambil kendali dari klien.

Melalui artikel ini, saya ikut bergabung dalam percakapan ini. Saya akan menanggapi kritik Jason dan, dalam prosesnya, mendeskripsikan pendekatan plugin saya, serta menunjukkan mengapa saya percaya hal ini sebenarnya dapat sangat cocok dengan Gutenberg.

Menggunakan COPE untuk mengekstrak metadata Gutenberg

Solusi saya bisa dianggap sebagai pendekatan ke-4, dan ini adalah caranya:

Untuk mendapatkan data Gutenberg yang akan mendukung GraphQL, jangan membuat skema tambahan di sisi PHP, atau menduplikasi data yang sudah ada. Sebaliknya, ekstrak data dari konten tersimpan pada blok, menggunakan strategi COPE ("Create Once, Publish Everywhere").

(COPE adalah strategi yang memungkinkan adanya satu sumber kebenaran tunggal untuk konten, dan mengeksposnya ke berbagai aplikasi. Dalam kasus kita, sumber kebenaran tunggal adalah data blok Gutenberg, sebagaimana disimpan dalam database. Saya telah mendeskripsikan COPE, dan implementasinya untuk WordPress, dalam artikel ini.)

Akhirnya, kita dapat menggunakan GraphQL untuk mengambil data yang telah diekstrak, untuk setiap blok Gutenberg, dengan memetakan semua blok ke dalam satu tipe Block.

Strategi ini adalah kompromi, bukan solusi definitif

Strategi ini tidak memecahkan masalah yang Jason tunjukkan: kurangnya skema di sisi server, yang akan memungkinkan pembuatan kontrak antara server dan klien.

COPE tidak dapat memecahkan masalah ini karena, hanya dari konten yang tersimpan, kita tidak dapat merekonstruksi skema:

  • Konten tersimpan tidak menunjukkan tipe field
  • Konten tersimpan tidak menunjukkan pembatasan apa yang dimiliki field (apakah nullable? apakah bilangan bulat positif? apakah string untuk email atau URL?)
  • Field nullable dapat memiliki nilai default, yang tidak akan ada dalam konten tersimpan

Namun, dengan menggunakan strategi COPE, dan satu tipe Block tunggal untuk merepresentasikan semua blok, Gato GraphQL dapat membangun integrasi yang sangat baik dengan Gutenberg, yang mengatasi keterbatasan yang ada.

Saya akan menjelaskannya sepanjang artikel ini.

Integrasi Gato GraphQL dengan Gutenberg

Solusi ini masih dalam pengembangan, tetapi saya sudah dapat menjelaskan bagaimana ia akan bekerja.

Alih-alih bergantung pada tipe yang berbeda per blok (seperti yang dilakukan WPGraphQL ketika mengandalkan plugin WPGraphQL for Gutenberg), Gato GraphQL akan menyediakan satu tipe Block tunggal untuk merepresentasikan semua blok.

Dalam query ini, field Post.blockDataItems mengambil daftar elemen Block dari post (untuk berbagai blok Gutenberg, termasuk paragraf, gambar, daftar, dan lainnya):

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

Jika kita ingin mengambil data untuk blok tertentu, kita dapat memfilter berdasarkan nama blok (core/paragraph, core/quote, dll).

Dalam query ini, kita hanya mengambil blok gambar:

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

Memeriksa tipe Block tunggal

Dengan pendekatan ini, respons dapat bervariasi tergantung pada konten yang tersimpan, bukan pada skema. Kualitas ini sekaligus menjadi keunggulan (karena membuat API lebih fleksibel) dan kelemahannya (kita tidak dapat menegakkan kontrak server-klien).

Setiap elemen Block mengandung dua properti:

  • name: Nama blok (core/paragraph, core/quote, dll)
  • meta: Metadata yang terkandung dalam blok

Setiap blok Gutenberg berbeda, mengandung data yang berbeda (konten paragraf, video YouTube, URL sumber gambar dan dimensinya, dll). Oleh karena itu, data yang terdapat dalam respons untuk field meta juga akan berbeda.

Dengan demikian, field meta telah dipetakan secara sederhana sebagai objek JSON (yang dapat mengandung data "mentah"), melalui tipe JSONObject yang bersesuaian dalam skema GraphQL.

Ini menghasilkan respons berikut:

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

Seperti yang dapat kita lihat, kita memiliki blok-blok berbeda yang mengambil properti berbeda:

  • core/paragraph memiliki properti content
  • core/image memiliki properti src, dan secara opsional properti width, height, dan caption (tidak muncul dalam respons di atas)
  • core/quote memiliki properti quote dan cite (untuk orang yang dikutip)
  • core/heading memiliki properti header dan size (nilai xl merepresentasikan <h2>, karena COPE memisahkan nilai dari aplikasi target, dalam hal ini sebuah website)
  • core/list memiliki properti items, yang merupakan daftar elemen

Mengapa tipe JSONObject tidak menjadi bagian dari spesifikasi

Tipe JSONObject yang saya deskripsikan di atas memungkinkan GraphQL untuk mengambil field "dinamis" (seperti field yang belum kita ketahui), atau field yang dapat memiliki banyak konfigurasi (sebagaimana mungkin terjadi pada blok Gutenberg).

Sekarang, spesifikasi GraphQL saat ini tidak mendukung tipe JSONObject atau Map. Dukungan untuk itu telah diminta, dengan alasan seperti:

[...] kurangnya fitur ini sangat bermasalah karena didukung di banyak sistem tipe dan layanan yang berinteraksi dengan GraphQL.

Hal ini menyebabkan implementasi resolver kustom di server, diikuti oleh transformasi kustom di klien, untuk menghadapi situasi di mana server saya mengirimkan Map, dan klien saya menginginkan Map, sementara GraphQL berada di tengah tanpa dukungan untuk Map. Ya, itu mungkin, dan saya telah melakukannya, tetapi ini cukup banyak boilerplate dan abstraksi yang tampaknya mengalahkan tujuan menulis spesifikasi API dalam GraphQL.

Fitur ini tidak didukung oleh spesifikasi karena menangani field dinamis bertentangan dengan perilaku strong typing GraphQL, yang merusak kontrak antara server dan klien.

Meski demikian, tipe ini dapat bermanfaat bagi Gutenberg, seperti yang akan saya tunjukkan nanti.

Masalah ketika menggunakan tipe berbeda per blok, dan registry sisi server

Jika membuat tipe GraphQL baru per blok, maka semua plugin harus memiliki blok-bloknya yang ditambahkan ke skema GraphQL. Hal ini dapat dilakukan secara otomatis dengan membuat semua blok mendefinisikan propertinya pada registry sisi server yang diusulkan.

Jika tidak, blok-blok mereka tidak akan tersedia untuk API, dan ini dapat menimbulkan konsekuensi tambahan. Dalam beberapa situasi, seluruh konten post yang di-query bisa menjadi tidak dapat diandalkan.

Ini mungkin terjadi ketika GraphQL berinteraksi dengan layanan berbasis cloud eksternal, yang menerapkan fungsi tertentu ke semua blok dalam post (bayangkan terjemahan, perbaikan tata bahasa, saran SEO, analitik, dll).

Mari kita lihat contohnya.

Karena kemampuan multibahasa akan ditambahkan ke Gutenberg pada fase 4, mari kita modelkan cara menerjemahkan semua blok dalam plugin, melalui panggilan ke Google Translate API yang dieksekusi melalui direktif @strTranslate.

(Setelah terjemahan awal berbasis API ini, pengguna dapat terus mengedit posting blog, dalam bahasa yang diterjemahkan, selalu di dalam editor WordPress.)

Blok yang berbeda mengandung potongan informasi berbeda yang harus diterjemahkan:

  • core/paragraph: teksnya
  • core/image: captionnya
  • core/quote: kutipannya, dan orang yang dikutip (karena bisa jadi gelar orang tersebut, seperti "The school headmaster")
  • core/heading: judulnya
  • core/list: semua item dalam daftar

Menggunakan tipe berbeda per blok, query yang dihasilkan mungkin terlihat seperti ini:

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

Dan seterusnya. Semakin banyak blok yang kita miliki, semakin panjang query ini, dengan mudah mencapai ratusan baris atau bahkan lebih.

Masalah yang jelas adalah query menjadi binatang liar yang perlu kita rawat.

Selain itu, kita perlu memperkenalkan fungsionalitas kustom agar ia bekerja untuk setiap blok. Misalnya, @strTranslate tidak bekerja dengan CoreListBlock.items, yang mengembalikan daftar string (yaitu mengembalikan [String], sementara direktif mengharapkan String), sehingga kita harus membuat @strTranslateList.

Kemudian core/table akan membutuhkan direktif kustom sendiri (@strTranslateTable?).

Dan blok pihak ketiga kustom mungkin membutuhkan direktif kustom mereka sendiri.

Lalu, saya melihat beberapa masalah lagi.

Semua atau tidak sama sekali

Sebuah posting blog dapat mengandung blok apa pun yang diinstal di editor WordPress. Dan kita tidak tahu terlebih dahulu (saat mengkode query) blok apa yang digunakan post tersebut.

Kemudian, dengan satu tipe per blok, jumlah tipe yang perlu ditangani dalam query tidak akan setara dengan jumlah blok dalam post. Sebaliknya, akan setara dengan jumlah blok yang diinstal di editor WordPress.

Apa yang terjadi jika kita memiliki 100 blok di situs kita, termasuk dari inti WordPress dan plugin? Maka kita perlu memiliki 100 tipe yang dipetakan ke skema GraphQL. Satu saja yang tidak dipetakan dapat merusak "kontrak konten", mengakibatkan beberapa blok diterjemahkan dari bahasa Inggris ke bahasa Prancis, sementara yang lain tetap dalam bahasa Inggris.

Akibatnya, kita tidak akan lagi dapat mempercayai post yang telah diterjemahkan, baik yang mengandung blok yang bermasalah maupun tidak. Jadi jika tidak semua blok ditambahkan ke registry, aplikasi dapat menjadi tidak dapat diandalkan.

Query harus diperbarui setiap kali blok baru diinstal

Demikian pula, setiap blok harus ditangani dalam query GraphQL. Artinya, setiap kali menginstal blok baru, kita perlu pergi ke kode aplikasi kita, memperbaruinya, dan men-deploy ulang.

Ini bukan hanya birokrasi ekstra: Kita tidak akan dapat menginstal blok di situs yang sedang berjalan, tanpa rasa takut merusak aplikasi (sampai semua query diperbarui).

GraphQL harus melayani WordPress, bukan sebaliknya

Mempertimbangkan kembali mengapa JSONObject tidak ditambahkan ke spesifikasi GraphQL, itu karena tidak sesuai dengan cara GraphQL bekerja.

Namun, di sini kita tidak benar-benar peduli dengan GraphQL. Kita hanya peduli dengan WordPress dan, lebih khusus lagi dalam kasus ini, Gutenberg.

Saat mengintegrasikan GraphQL dengan Gutenberg, GraphQL akan beroperasi dalam konteks WordPress. Artinya WordPress perlu memenuhi persyaratan dari GraphQL. Namun yang lebih penting, GraphQL-lah yang perlu memenuhi persyaratan dari WordPress.

Dan dalam kasus konflik, WordPress memiliki prioritas.

Jika sebuah fitur tidak cocok untuk GraphQL, tetapi tetap cocok untuk Gutenberg, haruskah dipertimbangkan?

Saya pikir ya.

Mari kita lihat bagaimana satu tipe Block tunggal dapat melayani Gutenberg dengan lebih baik.

Memecahkan masalah sebelumnya melalui satu tipe Block tunggal

Mengikuti contoh sebelumnya, menerjemahkan semua blok dalam sebuah post dari bahasa Inggris ke bahasa Prancis, menggunakan satu tipe Block tunggal, akan dilakukan seperti ini (atau sesuatu di sekitar konsep ini):

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

Hanya itu? Seluruh query? Untuk menerjemahkan semua blok? Ya.

Apakah ini akan bekerja untuk semua blok, dari inti maupun plugin, yang sudah ada maupun yang belum dibuat? Ya.

Apakah query ini terlihat sedikit aneh bagi Anda? Jika ya, itu karena menggunakan fitur GraphQL non-standar, yang hanya didukung oleh Gato GraphQL:

  • {{ translatablePaths }} adalah embeddable field, untuk memasukkan nilai sebuah field sebagai argumen ke field atau direktif lain (dalam kasus ini, tipe Block akan memiliki field translatableFields, yang nilainya diinjeksikan ke direktif @advancePointersInArray)
  • direktif dapat dikomposisi oleh direktif lain

Sekarang, jika sebuah fitur memenuhi persis apa yang dibutuhkan CMS, tetapi fiturnya non-standar, haruskah kita tetap menggunakannya? Saya pikir ya.

Saya juga telah mengajukan fitur-fitur ini untuk spesifikasi GraphQL (meskipun mereka tidak akan diterima):

Cara kerja tipe Block tunggal

Peringatan: bagian teknis di depan.

Tipe Block akan memiliki field translatablePaths, yang mengembalikan array properti dari JSONObject yang harus diterjemahkan:

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

@advancePointersInArray adalah meta-direktif: ia memodifikasi konteks untuk direktif berikutnya. Ia membuat direktif berikutnya menerima sub-elemen dari dalam JSONObject yang di-query, seperti properti content dari blok paragraf. Daftar path diperoleh melalui field translatablePaths, yang dievaluasi pada entitas yang sama yang di-query.

Kemudian, @underEachArrayItem adalah meta-direktif lainnya, yang melakukan iterasi atas daftar elemen dari entitas yang di-query, dan meneruskan referensi ke elemen yang diiterasi ke direktif berikutnya. Dalam kasus ini, ia mendapatkan semua daftar properti yang akan diterjemahkan untuk semua entitas, masing-masing bertipe String, dan meneruskan elemen String individual ke bawah.

Akhirnya, direktif @strTranslate menerima elemen bertipe String yang terdapat dalam JSONObject, dan menerjemahkannya langsung di sana, dalam JSONObject itu sendiri.

Harap perhatikan betapa fleksibelnya solusi ini. Cukup dengan menyediakan path ke string dalam JSONObject sudah cukup untuk mengakses nilai, memodifikasinya dengan @strTranslate (atau direktif lainnya), dan kemungkinan bahkan menyimpan nilai kembali ke DB (pekerjaan untuk mencapai ini saat ini sedang dalam proses).

Solusi ini sudah bekerja untuk core/list, karena semua elemen dalam daftar dapat dijangkau di bawah path mereka sendiri (items.0 adalah elemen ke-1 dalam array, dan seterusnya). Kemudian, ia dapat mengakses nilai String dari masing-masing, dan meneruskannya ke @strTranslate, sehingga tidak perlu membuat @strTranslateList.

Demikian pula, ini juga akan bekerja dengan core/table. Kita hanya perlu mengekspos data melalui properti cells, yang akan menjadi array 2 dimensi (satu untuk baris, yang mengandung satu untuk kolom). Kemudian, translatablePaths dapat menjangkau semua elemen sebagai ["cells.0.0", "cells.0.1", "cells.1.0", ...].

Dan ini akan bekerja untuk blok pihak ketiga mana pun. Untuk itu, kita harus memperhatikan bagaimana data blok disimpan, dan dari sana kita dapat menyimpulkan path ke propertinya.

Satu Block tunggal memerlukan konfigurasi, berdasarkan kode PHP

Memetakan blok-blok, agar kita tahu di mana menemukan properti metadatanya, dapat dilakukan melalui konfigurasi. Jadi kita dapat menanganinya dengan cara yang sangat fleksibel.

Di Gutenberg, ada dua tempat di mana properti dari blok dapat disimpan: sebagai atribut, atau di dalam konten yang dirender.

Misalnya, inilah cara blok core/image disimpan:

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

Dalam kasus ini, kita memiliki:

  1. Properti id, sizeSlug, dan linkDestination disimpan sebagai atribut
  2. Properti src disimpan di dalam konten yang dirender

Sekarang, saat melakukan query API, respons untuk blok core/image akan sebagai berikut:

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

API mengetahui cara mengambil properti dengan mengurai blok tersimpan di Gutenberg (itulah strategi COPE). Proses ini dapat dilakukan secara otomatis hingga tingkat tertentu, dan kemudian beberapa input manual melalui hook, atau melalui antarmuka pengguna.

Mendapatkan properti yang langsung dipetakan sebagai atribut adalah hal yang mudah. Server GraphQL sudah dapat mengambil semua atribut dari blok, dan membuatnya tersedia sebagai properti. Atau, jika kita ingin mendefinisikan secara eksplisit mana yang akan diekspos, kita dapat melakukannya melalui filter hook:

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

Properti yang disimpan dalam konten dapat diekstrak melalui beberapa regex:

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

Akhirnya, kita menunjukkan mana yang merupakan properti blok yang dapat diterjemahkan, agar @strTranslate dapat bekerja pada mereka:

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

Sekarang, properti-properti ini masih harus dipenuhi oleh seseorang, kemungkinan besar pengembang plugin. Oleh karena itu, memiliki registry sisi server akan membantu mencapai tujuan ini.

Tetapi bagaimana jika komunitas WordPress tidak mau menambahkan registry sisi server yang diusulkan? Nah, strategi ini dapat dengan mudah beradaptasi, karena pemetaan dapat dilakukan melalui kode PHP, seperti yang baru saja ditunjukkan.

Jika ada blok yang belum dipetakan, pengguna juga dapat melakukannya, cukup dengan mengetahui sedikit tentang Gutenberg, dan tidak perlu tahu apa-apa tentang GraphQL atau skema.

Selain itu, kita dapat membuat GraphQL mengingatkan pengguna ketika ada blok yang belum dipetakan (sehingga tidak dapat diterjemahkan). Kita dapat melakukan ini dengan menambahkan meta-direktif @if yang, jika kondisinya berlaku, mengeksekusi direktif @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"
          )
    }
  }
}

Solusi ini fleksibel dan sederhana, dan membuat GraphQL melayani WordPress, tanpa mengharuskan pengembang untuk mempelajari teknologi baru, atau mengubah cara kerja Gutenberg.

Kesimpulan

Ketika memikirkan bagaimana integrasi yang mungkin antara GraphQL dan Gutenberg akan terlihat (dari potensi inklusi dalam inti WordPress), kita harus memastikan bahwa GraphQL dapat menangani semua persyaratan masa depan dari Gutenberg, termasuk dukungan penuh untuk:

  • blok multibahasa
  • Full Site Editing
  • pengeditan kolaboratif
  • berinteraksi dengan layanan pihak ketiga di situs yang sedang berjalan

Semua ini harus dicapai semoga tanpa perlu mengubah Gutenberg (setidaknya, tidak secara signifikan), dan mengurangi tugas baru yang diperlukan dari pengembang plugin.

Dengan mempertimbangkan ini semua, saya percaya bahwa pendekatan ke-4 yang saya usulkan di sini memang dapat bekerja dengan sangat baik.


Berlangganan newsletter kami

Tetap update dengan semua pembaruan Gato GraphQL.