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 {}
}
Link dan Gambar #
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
}
}
Gunakanunsafe { }dengan hati-hati. HTML yang dimasukkan melaluiunsafetidak di-escape, sehingga bisa menjadi vektor serangan XSS jika kontennya berasal dari input pengguna. Hanya gunakanunsafeuntuk 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("<script>"))
}
}
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
FlowContentatauTagConsumer. 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 — blokunsafetidak melakukan escaping, sehingga bisa menjadi celah XSS jika menerima input dari pengguna. Selalu validasi dan sanitasi sebelum masuk keunsafe.- Integrasi Ktor sangat mulus —
call.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 =bukanclass =— di Kotlin,classadalah kata kunci yang dicadangkan. kotlinx.html menggunakan properticlasses(dengan huruf s) sebagai penggantinya.