Kotlinx.html

Kotlinx.html #

Kotlinx.html adalah library resmi JetBrains yang memungkinkan kamu membuat HTML menggunakan DSL (Domain Specific Language) Kotlin yang type-safe — bukan string template yang rawan typo dan injection. Setiap elemen HTML direpresentasikan sebagai fungsi Kotlin, setiap atribut direpresentasikan sebagai properti yang di-check compiler. Kamu tidak bisa menulis <div classe="container"> tanpa compiler langsung memperingatkannya karena classe bukan atribut yang valid. Ini menjadikan kotlinx.html pilihan yang menarik untuk generate HTML dari kode Kotlin — laporan, email, halaman web sederhana, atau komponen UI di aplikasi server-side. Artikel ini membahas semua elemen dan atribut HTML yang tersedia, cara membangun layout dan template, integrasi dengan Ktor, dan kapan menggunakan kotlinx.html versus template engine seperti Thymeleaf atau Freemarker.

Kapan Menggunakan kotlinx.html #

PILIH kotlinx.html jika:
  ✓ Generate HTML dari kode Kotlin dengan type safety
  ✓ Email HTML yang di-generate programatik
  ✓ Laporan atau dokumen HTML dinamis
  ✓ Server-side rendering sederhana tanpa framework frontend
  ✓ Komponen UI yang reusable sebagai fungsi Kotlin
  ✓ Testing output HTML dari kode yang sudah ada

PILIH template engine (Thymeleaf, Freemarker, Pebble) jika:
  ✓ Designer non-programmer perlu edit template HTML
  ✓ Template HTML yang sangat kompleks dengan banyak conditional
  ✓ Tim lebih familiar dengan template engine
  ✓ Ingin memisahkan HTML dari logika bisnis secara ketat

Setup #

// build.gradle.kts
dependencies {
    // kotlinx.html untuk JVM
    implementation("org.jetbrains.kotlinx:kotlinx-html-jvm:0.11.0")

    // Untuk JavaScript (Kotlin/JS)
    // implementation("org.jetbrains.kotlinx:kotlinx-html-js:0.11.0")
}

Dasar — Membuat HTML #

import kotlinx.html.*
import kotlinx.html.stream.createHTML

fun main() {
    // createHTML() — generate string HTML
    val html = createHTML().html {
        head {
            title("Halaman Pertama")
            meta(charset = "UTF-8")
            meta(name = "viewport", content = "width=device-width, initial-scale=1.0")
        }
        body {
            h1 { +"Halo, Kotlinx.html!" }
            p { +"Ini adalah paragraf pertama." }
            p {
                +"Ini paragraf dengan "
                strong { +"teks tebal" }
                +" dan "
                em { +"teks miring" }
                +"."
            }
        }
    }
    println(html)
}

Output:

<html>
  <head>
    <title>Halaman Pertama</title>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
  </head>
  <body>
    <h1>Halo, Kotlinx.html!</h1>
    <p>Ini adalah paragraf pertama.</p>
    <p>Ini paragraf dengan <strong>teks tebal</strong> dan <em>teks miring</em>.</p>
  </body>
</html>

Elemen HTML Dasar #

Heading dan Paragraf #

val html = createHTML().div {
    // Heading h1-h6
    h1 { +"Judul Utama (H1)" }
    h2 { +"Sub Judul (H2)" }
    h3 { +"Sub Sub Judul (H3)" }

    // Paragraf dan teks
    p { +"Paragraf biasa" }
    p {
        +"Teks dengan "
        strong { +"tebal" }
        +", "
        em { +"miring" }
        +", "
        u { +"garis bawah" }
        +", dan "
        del { +"dicoret" }
        +"."
    }

    // Quote
    blockquote {
        +"Ini adalah kutipan panjang yang biasanya diindentasi."
        cite { +"— Penulis" }
    }

    // Kode inline dan blok
    p { +"Gunakan fungsi "; code { +"println()" }; +" untuk output." }
    pre {
        code {
            +"""
                fun main() {
                    println("Hello!")
                }
            """.trimIndent()
        }
    }

    // Line break dan horizontal rule
    p { +"Baris pertama"; br; +"Baris kedua setelah break" }
    hr {}
}
val html = createHTML().div {
    // Link
    a(href = "https://kotlinlang.org") { +"Situs Kotlin" }
    a(href = "/halaman-lain", target = "_blank") { +"Buka di tab baru" }

    // Link dengan CSS class
    a(href = "#", classes = "btn btn-primary") { +"Tombol Link" }

    // Gambar
    img(src = "/gambar/foto.jpg", alt = "Foto Produk")
    img {
        src = "https://cdn.example.com/logo.png"
        alt = "Logo"
        width = "200"
        height = "100"
        classes = "responsive-image"
    }

    // Gambar sebagai link
    a(href = "/produk/1") {
        img(src = "/gambar/produk-1.jpg", alt = "Laptop Gaming")
    }
}

Daftar (List) #

val html = createHTML().div {
    // Unordered list
    ul {
        li { +"Item pertama" }
        li { +"Item kedua" }
        li {
            +"Item ketiga dengan sub-list"
            ul {
                li { +"Sub item A" }
                li { +"Sub item B" }
            }
        }
    }

    // Ordered list
    ol {
        li { +"Langkah pertama" }
        li { +"Langkah kedua" }
        li { +"Langkah ketiga" }
    }

    // Definition list
    dl {
        dt { +"Kotlin" }
        dd { +"Bahasa pemrograman modern untuk JVM" }
        dt { +"Ktor" }
        dd { +"Framework web Kotlin-native dari JetBrains" }
    }
}

Atribut, CSS, dan Kelas #

val html = createHTML().div {
    // Atribut id dan class
    div {
        id = "container"
        classes = "container mx-auto"

        p {
            id = "intro"
            classes = "text-lg text-gray-700"
            +"Paragraf dengan ID dan kelas CSS."
        }
    }

    // CSS inline
    div {
        style = "background-color: #f0f0f0; padding: 16px; border-radius: 8px;"
        +"Div dengan style inline"
    }

    // Atribut data-*
    div {
        attributes["data-id"] = "produk-123"
        attributes["data-kategori"] = "elektronik"
        attributes["aria-label"] = "Daftar produk"
        +"Div dengan data attributes"
    }

    // Tombol dengan berbagai atribut
    button(type = ButtonType.button) {
        id = "btn-submit"
        classes = "btn btn-primary"
        disabled = false
        onClick = "handleClick()"
        +"Klik Saya"
    }
}

Form #

Form adalah salah satu elemen HTML yang paling sering dibutuhkan:

fun buatFormDaftar(): String = createHTML().form {
    action = "/daftar"
    method = FormMethod.post
    encType = FormEncType.multipartFormData  // untuk upload file
    classes = "form-daftar"

    div(classes = "form-group") {
        label {
            htmlFor = "nama"
            +"Nama Lengkap"
        }
        textInput(name = "nama") {
            id = "nama"
            placeholder = "Masukkan nama lengkap"
            required = true
            classes = "form-control"
        }
    }

    div(classes = "form-group") {
        label {
            htmlFor = "email"
            +"Email"
        }
        emailInput(name = "email") {
            id = "email"
            placeholder = "[email protected]"
            required = true
            classes = "form-control"
        }
    }

    div(classes = "form-group") {
        label {
            htmlFor = "sandi"
            +"Kata Sandi"
        }
        passwordInput(name = "sandi") {
            id = "sandi"
            minLength = "8"
            required = true
            classes = "form-control"
        }
    }

    div(classes = "form-group") {
        label {
            htmlFor = "umur"
            +"Umur"
        }
        numberInput(name = "umur") {
            id = "umur"
            min = "17"
            max = "100"
            classes = "form-control"
        }
    }

    div(classes = "form-group") {
        label {
            htmlFor = "kota"
            +"Kota"
        }
        select {
            id = "kota"
            name = "kota"
            classes = "form-select"
            option {
                value = ""
                +"-- Pilih Kota --"
            }
            listOf("Jakarta", "Bandung", "Surabaya", "Medan", "Makassar").forEach { kota ->
                option {
                    value = kota.lowercase()
                    +kota
                }
            }
        }
    }

    div(classes = "form-group") {
        label { +"Jenis Kelamin" }
        div {
            label {
                radioInput(name = "jenis_kelamin") {
                    value = "L"
                }
                +" Laki-laki"
            }
            label {
                radioInput(name = "jenis_kelamin") {
                    value = "P"
                }
                +" Perempuan"
            }
        }
    }

    div(classes = "form-group") {
        label {
            checkBoxInput(name = "setuju") {
                required = true
            }
            +" Saya menyetujui syarat dan ketentuan"
        }
    }

    div(classes = "form-group") {
        label { +"Foto Profil" }
        fileInput(name = "foto") {
            accept = "image/*"
            classes = "form-control"
        }
    }

    div(classes = "form-group") {
        label {
            htmlFor = "bio"
            +"Bio"
        }
        textArea {
            id = "bio"
            name = "bio"
            rows = "4"
            placeholder = "Ceritakan tentang dirimu..."
            classes = "form-control"
        }
    }

    submitInput {
        value = "Daftar Sekarang"
        classes = "btn btn-primary btn-lg"
    }
}

Tabel #

data class Produk(val id: Int, val nama: String, val harga: Double, val stok: Int)

fun buatTabelProduk(produk: List<Produk>): String = createHTML().div {
    classes = "table-responsive"

    table {
        classes = "table table-striped table-hover"

        thead {
            tr {
                th { +"#" }
                th { +"Nama Produk" }
                th { +"Harga" }
                th { +"Stok" }
                th { +"Status" }
                th { +"Aksi" }
            }
        }

        tbody {
            if (produk.isEmpty()) {
                tr {
                    td {
                        colSpan = "6"
                        classes = "text-center"
                        +"Tidak ada produk"
                    }
                }
            } else {
                produk.forEachIndexed { index, p ->
                    tr {
                        if (p.stok == 0) classes = "table-danger"
                        else if (p.stok < 10) classes = "table-warning"

                        td { +"${index + 1}" }
                        td { +p.nama }
                        td { +"Rp${"%,d".format(p.harga.toLong())}" }
                        td { +"${p.stok} unit" }
                        td {
                            span {
                                classes = if (p.stok > 0) "badge bg-success" else "badge bg-danger"
                                +(if (p.stok > 0) "Tersedia" else "Habis")
                            }
                        }
                        td {
                            a(href = "/produk/${p.id}", classes = "btn btn-sm btn-info me-1") {
                                +"Detail"
                            }
                            a(href = "/produk/${p.id}/edit", classes = "btn btn-sm btn-warning") {
                                +"Edit"
                            }
                        }
                    }
                }
            }
        }

        tfoot {
            tr {
                td {
                    colSpan = "3"
                    classes = "fw-bold"
                    +"Total: ${produk.size} produk"
                }
                td {
                    classes = "fw-bold"
                    +"${produk.sumOf { it.stok }} unit"
                }
                td {}
                td {}
            }
        }
    }
}

Template dan Layout — Fungsi Reusable #

Kekuatan kotlinx.html muncul saat kamu membuat komponen yang reusable sebagai fungsi Kotlin:

// Layout dasar yang bisa digunakan ulang
fun FlowContent.layoutDasar(
    judul: String,
    cssExtra: String = "",
    konten: FlowContent.() -> Unit
) {
    val baseHtml = createHTML()
    // Tapi lebih umum menggunakan extension function pada TagConsumer
}

// Pola lebih idiomatis: buat fungsi yang menerima TagConsumer
fun TagConsumer<*>.halamanDasar(
    judul: String,
    konten: BODY.() -> Unit
) {
    html {
        head {
            meta(charset = "UTF-8")
            meta(name = "viewport", content = "width=device-width, initial-scale=1.0")
            title(judul)
            link(rel = "stylesheet", href = "https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css")
        }
        body {
            nav(classes = "navbar navbar-expand-lg navbar-dark bg-primary") {
                div(classes = "container") {
                    a(classes = "navbar-brand", href = "/") { +"MyApp" }
                }
            }

            main(classes = "container my-4") {
                konten()
            }

            footer(classes = "bg-light py-4 mt-auto") {
                div(classes = "container text-center text-muted") {
                    +"© 2024 MyApp. Semua hak dilindungi."
                }
            }

            script(src = "https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js") {}
        }
    }
}

// Komponen card yang reusable
fun FlowContent.card(
    judul: String,
    kelas: String = "",
    konten: FlowContent.() -> Unit
) {
    div(classes = "card $kelas") {
        div(classes = "card-header") {
            h5(classes = "card-title mb-0") { +judul }
        }
        div(classes = "card-body") {
            konten()
        }
    }
}

// Komponen alert
fun FlowContent.alert(pesan: String, tipe: String = "info") {
    div(classes = "alert alert-$tipe alert-dismissible fade show") {
        attributes["role"] = "alert"
        +pesan
        button(type = ButtonType.button, classes = "btn-close") {
            attributes["data-bs-dismiss"] = "alert"
            attributes["aria-label"] = "Close"
        }
    }
}

// Penggunaan
fun buatHalamanProduk(produk: List<Produk>, pesanSukses: String? = null): String {
    return createHTML().halamanDasar("Daftar Produk") {
        h1(classes = "mb-4") { +"Daftar Produk" }

        pesanSukses?.let {
            alert(it, "success")
        }

        card("Semua Produk") {
            a(href = "/produk/tambah", classes = "btn btn-primary mb-3") {
                +"+ Tambah Produk"
            }
            unsafe {
                +buatTabelProduk(produk)
            }
        }
    }
}

Integrasi dengan Ktor #

import io.ktor.server.application.*
import io.ktor.server.html.*
import io.ktor.server.routing.*
import kotlinx.html.*

fun Route.halamanHtmlRoutes() {
    // Ktor menyediakan call.respondHtml{} untuk menghasilkan HTML langsung
    get("/produk") {
        val produk = ambilSemuaProduk()

        call.respondHtml {
            halamanDasar("Daftar Produk") {
                h1 { +"Daftar Produk" }
                unsafe { +buatTabelProduk(produk) }
            }
        }
    }

    get("/produk/{id}") {
        val id = call.parameters["id"]?.toIntOrNull()
            ?: return@get call.respondHtml(io.ktor.http.HttpStatusCode.BadRequest) {
                body { p { +"ID tidak valid" } }
            }

        val produk = ambilProdukById(id)
            ?: return@get call.respondHtml(io.ktor.http.HttpStatusCode.NotFound) {
                halamanDasar("Tidak Ditemukan") {
                    div(classes = "alert alert-danger") {
                        +"Produk dengan ID $id tidak ditemukan"
                    }
                    a(href = "/produk", classes = "btn btn-secondary") { +"← Kembali" }
                }
            }

        call.respondHtml {
            halamanDasar("Detail: ${produk.nama}") {
                h1 { +produk.nama }
                p { +"Harga: Rp${"%,d".format(produk.harga.toLong())}" }
                p { +"Stok: ${produk.stok} unit" }
            }
        }
    }

    get("/produk/tambah") {
        call.respondHtml {
            halamanDasar("Tambah Produk") {
                h1 { +"Tambah Produk Baru" }
                unsafe { +buatFormDaftar() }
            }
        }
    }
}

// Placeholder functions
fun ambilSemuaProduk() = listOf(
    Produk(1, "Laptop Gaming", 15_000_000.0, 10),
    Produk(2, "Mouse Wireless", 250_000.0, 0)
)
fun ambilProdukById(id: Int) = ambilSemuaProduk().find { it.id == id }

Generate Email HTML #

Salah satu use case paling umum kotlinx.html adalah generate email HTML:

fun buatEmailKonfirmasiPesanan(
    namaPengguna: String,
    pesananId: String,
    items: List<Pair<String, Double>>,
    total: Double
): String = createHTML().html {
    head {
        meta(charset = "UTF-8")
        style {
            +"""
                body { font-family: Arial, sans-serif; margin: 0; padding: 0; background: #f4f4f4; }
                .container { max-width: 600px; margin: 20px auto; background: white; padding: 30px; border-radius: 8px; }
                .header { background: #2563eb; color: white; padding: 20px; border-radius: 8px 8px 0 0; text-align: center; }
                table { width: 100%; border-collapse: collapse; }
                th, td { padding: 12px; border-bottom: 1px solid #e5e7eb; text-align: left; }
                th { background: #f9fafb; }
                .total { font-size: 18px; font-weight: bold; color: #2563eb; }
                .footer { text-align: center; color: #6b7280; font-size: 12px; margin-top: 20px; }
            """.trimIndent()
        }
    }
    body {
        div(classes = "container") {
            div(classes = "header") {
                h2 { +"✅ Pesanan Dikonfirmasi" }
                p { +"Terima kasih atas pesananmu!" }
            }

            div(classes = "content") {
                p { +"Halo, $namaPengguna!" }
                p { +"Pesanan kamu dengan nomor #$pesananId telah berhasil dikonfirmasi." }

                h3 { +"Detail Pesanan" }
                table {
                    thead {
                        tr {
                            th { +"Produk" }
                            th { +"Harga" }
                        }
                    }
                    tbody {
                        items.forEach { (nama, harga) ->
                            tr {
                                td { +nama }
                                td { +"Rp${"%,d".format(harga.toLong())}" }
                            }
                        }
                    }
                    tfoot {
                        tr {
                            td { strong { +"Total" } }
                            td(classes = "total") {
                                +"Rp${"%,d".format(total.toLong())}"
                            }
                        }
                    }
                }

                p { +"Pesananmu akan segera diproses dan dikirimkan ke alamatmu." }

                div(classes = "footer") {
                    p { +"Email ini dikirim otomatis. Mohon tidak membalas email ini." }
                    p { +"© 2024 MyApp. Jl. Contoh No. 1, Jakarta" }
                }
            }
        }
    }
}

Unsafe — Menyisipkan HTML Raw #

Untuk menyisipkan HTML yang sudah ada ke dalam kotlinx.html:

val html = createHTML().div {
    // Sisipkan HTML raw (tidak di-escape)
    unsafe {
        +"""<p>Ini adalah <b>HTML raw</b> yang tidak di-escape.</p>"""
        +buatTabelProduk(listOf(Produk(1, "Laptop", 15_000_000.0, 5)))
    }

    // Bandingkan dengan teks biasa yang di-escape
    p {
        +"<script>alert('XSS')</script>"  // Ini di-escape: tampil sebagai teks literal
    }
}
Gunakan unsafe { } dengan hati-hati. HTML yang dimasukkan melalui unsafe tidak di-escape, sehingga bisa menjadi vektor serangan XSS jika kontennya berasal dari input pengguna. Hanya gunakan unsafe untuk HTML yang kamu kontrol sepenuhnya.

Testing Output HTML #

import org.junit.jupiter.api.Test
import kotlin.test.assertTrue
import kotlin.test.assertFalse

class TemplateHtmlTest {

    @Test
    fun `tabel produk mengandung semua nama produk`() {
        val produk = listOf(
            Produk(1, "Laptop Gaming", 15_000_000.0, 10),
            Produk(2, "Mouse Wireless", 250_000.0, 0)
        )

        val html = buatTabelProduk(produk)

        assertTrue(html.contains("Laptop Gaming"))
        assertTrue(html.contains("Mouse Wireless"))
        assertTrue(html.contains("Rp15,000,000"))
    }

    @Test
    fun `produk stok habis mendapat kelas table-danger`() {
        val produk = listOf(Produk(1, "Mouse", 250_000.0, 0))
        val html = buatTabelProduk(produk)

        assertTrue(html.contains("table-danger"))
        assertTrue(html.contains("Habis"))
    }

    @Test
    fun `tabel kosong menampilkan pesan tidak ada produk`() {
        val html = buatTabelProduk(emptyList())
        assertTrue(html.contains("Tidak ada produk"))
    }

    @Test
    fun `form daftar mengandung field yang diperlukan`() {
        val form = buatFormDaftar()

        assertTrue(form.contains("""name="nama""""))
        assertTrue(form.contains("""name="email""""))
        assertTrue(form.contains("""name="sandi""""))
        assertTrue(form.contains("""type="submit""""))
    }

    @Test
    fun `XSS tidak bisa melalui teks biasa`() {
        val htmlDenganScript = createHTML().p {
            +"<script>alert('xss')</script>"
        }
        // Harus di-escape
        assertFalse(htmlDenganScript.contains("<script>"))
        assertTrue(htmlDenganScript.contains("&lt;script&gt;"))
    }
}

Ringkasan #

  • Type-safe HTML — kotlinx.html memastikan kamu tidak bisa menulis atribut atau elemen yang tidak valid. Compiler menangkap kesalahan seperti nama atribut salah ketik sebelum aplikasi berjalan.
  • Komponen sebagai fungsi — buat elemen HTML reusable sebagai extension function pada FlowContent atau TagConsumer. Ini adalah cara idiomatis membuat “komponen” tanpa framework frontend.
  • + untuk teks — gunakan operator + diikuti string untuk menyisipkan teks. Teks otomatis di-escape dari karakter HTML berbahaya seperti <, >, dan &.
  • unsafe { } hanya untuk HTML terpercaya — blok unsafe tidak melakukan escaping, sehingga bisa menjadi celah XSS jika menerima input dari pengguna. Selalu validasi dan sanitasi sebelum masuk ke unsafe.
  • Integrasi Ktor sangat muluscall.respondHtml { } di Ktor langsung menghasilkan respons HTML dengan content-type yang tepat. Tidak perlu konfigurasi tambahan.
  • Email HTML adalah use case sempurna — untuk generate email konfirmasi, notifikasi, atau laporan dalam format HTML, kotlinx.html jauh lebih aman dan lebih mudah di-maintain dari string concatenation.
  • Test output HTML dengan contains() — test sederhana yang memeriksa keberadaan string tertentu di output HTML sudah cukup untuk sebagian besar kasus. Tidak perlu parsing HTML yang kompleks.
  • Gunakan classes = bukan class = — di Kotlin, class adalah kata kunci yang dicadangkan. kotlinx.html menggunakan properti classes (dengan huruf s) sebagai penggantinya.

← Sebelumnya: Vert.x   Berikutnya: Strings →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact