console.log('')

Konfigurasi Gambar Next.js dengan remark dan MDX

Improvisasi /
10 menit dibaca

Kedengarannya mungkin konyol tetapi saya jujur: salah satu alasan kenapa saya memilih Next.js untuk merakit blog ini adalah karena saya tidak tahu cara mudah untuk mengoptimalkan gambar secara otomatis, tanpa harus berlangganan ke sebuah layanan atau memasang suatu plugin pihak ketiga. Serius.

Padahal gambar merupakan salah satu aset yang paling krusial di web, dan sering kali menjadi dalang yang membuat web kita mudah mogok. Beberapa orang bahkan menyarankan, “Ketimbang belajar berbagai jurus optimasi kode, lebih baik luangkan waktu buat optimasi gambar.”

Dunia pengembangan web memang penuh perjudian.

Kita semua tahu jawabannya. Masalahnya saya tidak mau menggunakan Image CDN, setidaknya untuk blog pribadi dan proyek jangka panjang yang butuh puluhan gambar, karena malas jika suatu waktu tiba masanya harus migrasi. Yah, tidak ada jaminan mereka akan menyenangkan selamanya, bukan? Apalagi kalau kita mendaftar di tingkat gratis.

Di sisi lain, untuk plugin pihak ketiga, saya selalu merasa kurang nyaman karena mereka kerap bikin jengkel dalam dua hal:  memperberat ukuran aplikasi dan minim konfigurasi. Lagi-lagi, terutama kalau kita memakai versi yang gratis.

Jadi, permisa, saya merasa senang ketika mengetahui fitur-fitur bawaan Next.js cukup menarik, tetapi sialnya, fitur optimasi gambar Next.js bekerja dengan aturan yang sedikit aneh dan lebih sialnya lagi, bentrok dengan mesin MDX yang saya gunakan.

Pertama-tama mari kita bahas komponen gambar Next.js terlebih dahulu.

Sejak di versi 11, komponen gambar Next.js menerima dua tipe props untuk src:

  • Object, berupa impor statis
  • String, berupa jalur absolut/relatif atau tautan eksternal

Berikut contoh penerapannya:

import Image from 'next/image'
import me from 'assets/me.png'

// impor statis
function TheEazyWay() {
  return <Image src={me} placeholder="blur" />
}

// jalur absolut
function TheHassleOne() {
  return (
    <Image
      src="/me.png"
      height={200}
      width={200}
      layout="responsive"
    />
  )
}

Impor statis adalah cara gampang karena ia akan otomatis mendeteksi nilai lebar dan tinggi gambar (keduanya dibutuhkan untuk komponen gambar Next.js), dan otomatis menambahkan gambar yang telah dibuat buram dalam format base64 sebagai placeholder sebelum gambar asli dimuat.

Jadi di balik layar, kode pertama akan diubah menjadi seperti ini:

function TheEazyWay() {
  return (
    <Image
      src={me}
      // height={200} otomatis ditambahkan
      // width={200} otomatis ditambahkan
      placeholder="blur"
      // blurDataUrl="data:..." otomatis dibuatkan
    />
  )
}

Sementara untuk cara kedua, semua nilai termasuk lebar dan tinggi gambar harus kita isi secara manual, dan kalau mau memakai mode buram harus membuat placeholder manual.

Baiklah. Mengetahui lebar dan tinggi gambar memang bukan pekerjaan yang merepotkan, hanya saja, ayolah, ini 2021 dan kerangka kerja mestinya melakukan lebih banyak kerja. Saya ingin itu berjalan otomatis.

Jadi jika saya ingin menyisipkan gambar di tulisan tanpa harus repot mengisi berbagai props yang dibutuhkan oleh Next.js, saya hanya perlu menjadikan komponen gambar Next.js sebagai komponen MDX dan memakai cara pertama, bukan?

Secara teori, iya. Tetapi pada praktiknya tidak semudah itu, Kamerad!

Masalah: Kompatibilitas mdx-bundler

Seperti yang saya ceritakan di tulisan sebelumnya, ada banyak kompiler MDX untuk Next.js. Masing-masing dari mereka memiliki keunggulan dan batasan yang berbeda, dan sayangnya hampir tidak ada yang mendekati kebutuhan saya di blog ini kecuali mdx-bundler.

mdx-bundler sebetulnya bagus dan saya sangat puas. Di balik terpal ia memakai esbuild (salah satu bundler JavaScript tercepat di muka bumi) dan memiliki banyak fitur bawaan yang membantu. Konflik datang dari salah satu fiturnya: image bundling.

Di mdx-bundler, jika kita membuat pernyataan impor pada gambar, itu akan otomatis dideteksi dan diproses oleh esbuild.

import me from '../assets/me.png'

Some markdown here.

<Image src={me} />

// error: No loader is configured for ".png" files: content/assets/me.png

Dokumentasi mdx-bundler mengatakan kita harus membuat konfigurasi loader seperti ini:

const { code } = await bundleMDX(source, {
  cwd: 'content/assets',
  esbuildOptions: options => {
    options.loader = {
      ...options.loader,
      '.png': 'dataurl'
    }

    return options
  },
})

Ada dua tipe loader yang diterima esbuild untuk gambar: dataurl dan file. Yang pertama akan mengubah gambar menjadi base64 dan yang kedua akan menyalin fail gambar ke output directory, jadi kita harus memberi tahu esbuild di mana direktorinya.

Masalahnya itu tetap tidak berhasil dengan komponen gambar Next.js karena loader bawaan Next.js sedikit unik. Next.js akan mengubah tautan gambar menjadi seperti ini:

<img src="/_next/image?url=%2Fpath%2Fto%2Fimage.png&w=500&q=75" />  

Kejutan? Next.js otomatis menghasilkan sembilan nilai w (width) yang berbeda, dan menambahkan srcSet di HTML.

Pilihannya ada tiga: gunakan loader esbuild tetapi kehilangan manfaat optimasi gambar Next.js, gunakan loader Next.js dengan risiko harus mau repot mengisi setiap props secara manual, atau ya, cara terakhir, buat plugin remark sendiri.

Solusi: Buat plugin remark Sendiri

Aplikasi web secara garis besar hanyalah kumpulan kode. Sementara konten adalah data. Bagaimana cara kita memiliki data di antara kode? Tepat.

Markdown adalah salah satu tipe data yang umum digunakan. Tetapi Markdown bukan bahasa yang bisa dipahami oleh web secara langsung; web butuh prosesor untuk mentransformasi sintaks Markdown menjadi HTML yang valid. Nah, salah satu prosesor yang jamak di dunia Jamstack adalah remark.

Yang menarik dari remark, ia cukup longgar untuk memberi kita kebebasan dalam mengutak-atik pemrosesan. Kita bisa, umpama, menyuruh remark untuk mengubah perilaku dari yang mulanya hanya menghasilkan A menjadi B atau A++. Seperti bilang, “Jika kamu melihat sintaks ini, tolong ubah menjadi ini.”

Cara paling mudah untuk membayangkan konsep plugin remark adalah mengingat alkimia: kimia pada Abad Pertengahan yang berusaha mencari cara untuk mengubah logam biasa menjadi emas. Tepat seperti itulah plugin remark bekerja. Anggap saja data Markdown itu sebagai logam yang ingin kita ubah ke bentuk yang lebih mulia.

Jadi mari buka Google dan cari tahu apa yang harus dilakukan jika ingin membuat plugin remark sendiri!

Google memberi tahu saya bahwa di ekosistem remark ada sebuah utilitas yang bisa membantu kita “mengunjungi” suatu node tertentu dengan cepat dan tepat: unist-util-visit.

import { visit } from 'unist-util-visit'

function remarkImage() {
  return (tree) => {
    visit(tree,
      // kunjungi tag HTML 'p' yang berisi 'img'
      (node) =>
        node.type === 'paragraph' &&
        node.children.some((n) => n.type === 'image'),    
      (node) => {
        // buat variabel untuk gambar
        const image = node.children.find((n) => n.type === 'image')

        // peretasan di sini

      }
    )
  }
}

Karena saya ingin menggunakan loader Next.js, berarti tugas plugin remark ini  adalah otomatis mengisi nilai props di next/image yang tidak bisa saya isi lewat sintaks Markdown. Paling minim ia memberi saya nilai untuk lebar dan tinggi gambar.

Beruntung, di internet sudah ada orang baik yang membuat modul Node.js untuk keperluan itu: image-size.

import sizeOf from 'image-size'

// lanjutan dari potongan kode di atas
const image = node.children.find((n) => n.type === 'image')
// tambahkan folder 'public' ke jalur gambar
const images = `${process.cwd()}/public${image.url}`

if (image.url.startsWith('/')) {
  // dapatkan nilai dari image-size
  const dimensions = sizeOf(images)

  // lanjutkan peretasan di sini

}

Kenapa membuat pengondisian untuk gambar yang hanya ada di file system (lokal)? Perhatikan jalur images. Saya menambahkan folder public di sana karena itulah pola yang digunakan Next.js untuk gambar lokal.

// ini sintaks di Markdown
![alt](/static/image.png)

// ini folder tempat gambar disimpan
public/static/image.png

Dengan membuat pengondisian di atas, saya menjaga plugin agar tidak meledak jika saya menyisipkan gambar dari tautan eksternal.

Ya, saya memang jarang memakai gambar eksternal di mode produksi (makanya saya tidak menambahkan else di kode), tetapi kadang-kadang saya memakai gambar eksternal di mode pengembangan. Jadi daripada langsung diunduh, gambar bisa ditinjau terlebih dahulu tanpa menambah beban ke kompiler.

Melempar dua burung dengan satu batu.

Lanjut.

const dimensions = sizeOf(images)

// ubah node asli menjadi komponen next/image
image.type = 'mdxJsxFlowElement',
image.name = 'Image', // nama komponen MDX
image.attributes = [
  {
    type: 'mdxJsxAttribute',
    name: 'src',
    value: image.url // dari data di Markdown
  },
  {
    type: 'mdxJsxAttribute',
    name: 'alt',
    value: image.alt // dari data di Markdown
  },
  {
    type: 'mdxJsxAttribute',
    name: 'height',
    value: dimensions.height // dari image-size
  },
  {
    type: 'mdxJsxAttribute',
    name: 'width',
    value: dimensions.width // dari image-size
  }

// ubah parent node dari 'p' menjadi 'div' untuk
// menghindari kesalahan penguraian HTML
node.type = 'div'
node.children = [image]

Bagian plugin sudah selesai. Itu harusnya berfungsi sesuai rencana.

Selanjutnya daftarkan plugin ke kompiler dan buat komponen MDX untuk menangani gambar.

import NextImage from 'next/image'

// sesuaikan nama komponen dengan nama yang didaftarkan
// di plugin remark
function Image({ ...rest }) {
  return <NextImage {...rest} layout="responsive" />  
}

const MDXComponents = { Image }

Boom.

Itu berhasil. Tetapi saya masih kurang puas. Ada dua hal yang ingin saya tambahkan. Pertama, saya ingin bisa membuat takarir (caption) tetapi terbatas oleh sintaks Markdown. Kedua, saya ingin hasil dari gambar yang dioptimasi ini menjadi lebih semantik.

Untuk mencapai yang pertama, karena sintaks gambar di Markdown hanya menyediakan key-value untuk alt dan src, cara paling logisnya adalah memanipulasi alt.

Iya, tetapi bagaimana caranya?

Tiba-tiba saya teringat pada satu plugin remark di Gatsby yang melakukan hal serupa: gatsby-remark-code-titles. Plugin ini memanfaatkan nilai dari node language di sintaks Markdown untuk membuat blok kode.

// ini sintaks blok kode di Markdown
```js
console.log('Hello World!')
```

// ini cara gatsby-remark-code-titles bekerja
```js:title=example.js
console.log('Hello World!')
```

// hasil: <div class="gatsby-code-title">example.js</div>

Aha! Tiru saja polanya!

Jadi saya mencoba meniru cara kerja plugin buatan Dustin Schau itu, dengan percobaan awal menerapkannya di kode blok saya sendiri.

Gagal.

Mungkin karena itu ditulis untuk prosesor remark versi lama? Buka Google dan ketik "remark-code-titles". Ketemu. Ada satu plugin yang baru ditulis enam bulan lalu, jadi mestinya kode di plugin tersebut relevan dengan versi remark sekarang.

Buka repositorinya, lihat kode sumbernya. Amati, tiru, modifikasi.

Test
console.log('Test')

Gotcha.

Saatnya terapkan di plugin gambar!

const image = node.children.find((n) => n.type === 'image')
const a = image.alt

let alt = ''
let caption = ''
let figcaption = 'figcaption'

// pisahkan alt dengan caption
if (a.includes(';')) {
  alt = a.slice(0, a.search(';'))
  caption = a.slice(a.search(';') + 1, a.length)
  figcaption = figcaption
} else {
  // jika caption tidak ada, kembalikan alt
  alt = a
  caption = ''
  figcaption = null
}

// tambahkan children di node image
image.children = [
  {
    type: 'mdxJsxFlowElement',
    name: figcaption,
    children: [
      {
        type: 'text',
        value: caption,
      },
    ],
  },
]

Catatan: saya memilih titik koma sebagai tanda pemisahnya dengan pertimbangan ada kemungkinan titik dua digunakan di takarir.

Berikutnya sesuaikan komponen MDX.

import NextImage from 'next/image'
import { image } from 'styles/config'

function Image({ children, ...rest }) {
  return (
    <figure>
      <NextImage
        {...rest}
        // tambahkan placeholder blur agar lebih cihui 
        placeholder="blur"
        blurDataUrl={image.blur}
        layout="responsive"
      />
      {children}
    </figure>
  )
}

Saya juga sengaja menyimpan <figure> di tingkat komponen alih-alih di plugin remark karena komponennya akan digunakan di laman lain selain pos blog.

Dengan perombakan di atas, beginilah cara saya sekarang menyisipkan gambar di Markdown:

![For machine;For human](/image.png)  

Yang akan diurai menjadi:

<figure>
  <img alt="For machine" title="For machine" src="/_next/image?url=%2Fimage.png&w=450&q=75" />
  <figcaption>For human</figcaption>
</figure>

Cakep!

Kesimpulan

Saya sangat senang dengan hasil dari eksperimen ini. Selain memuaskan, saya juga baru kepikiran bahwa merekayasa di tingkat remark jauh lebih aman dari utang teknis masa depan.

Pertimbangkan kasus, misalnya, dua tahun ke depan saya mengganti mesin blog ini dari Next.js ke Remix (atau kerangka kerja apa pun yang mirip-mirip). Selama mesin baru itu mendukung remark dan MDX, saya hanya perlu menyesuaikan plugin remark dengan komponen gambar yang baru, tanpa menyunting sintaks gambar di Markdown karena sintaksnya tetap valid.

Sekarang ada tiga burung yang terlempar.