September 27, 2026
Arsitektur Situs fanny.dev (Hugo + Tailwind v4).
Bedah isi dapur situs ini: kenapa Hugo, cara konten diorganisasi, layout shell dua kolom, pipeline Tailwind v4 lewat hugo_stats.json, dark mode, filter project di sisi klien, data remote saat build, SEO, Giscus, minifikasi, deploy ke Vercel, dan pelajaran yang didapat.
Situs yang sedang kamu baca ini adalah versi kesekian dari website portofolioku. Ceritanya, dari WordPress + Elementor sampai Hugo, sudah aku tulis di Perjalanan Website Portofolio , dan ringkasan project-nya ada di Hugo Portfolio & Blog . Catatan ini melihat dari sisi yang berbeda: bagaimana situs ini disusun secara teknis. File apa ada di mana, kenapa diputuskan begitu, dan jebakan apa saja yang sempat bikin bingung.
Semua potongan kode di bawah diambil langsung dari repo, kadang dipendekkan supaya fokus.
Kenapa Hugo
Kebutuhan situs ini sebenarnya sederhana: blog, catatan teknis (Shelves), daftar project, jurnal, halaman about, dan resume. Tidak ada login, tidak ada database, tidak ada konten yang berubah per pengunjung. Untuk kebutuhan seperti ini, static site generator adalah pilihan yang paling masuk akal:
- Cepat. Hasil build hanya HTML, CSS, dan sedikit JavaScript. Tidak ada server yang harus merender halaman saat diminta, jadi TTFB praktis hanya sebatas kecepatan CDN.
- Murah dan aman. Tidak ada runtime server berarti tidak ada yang perlu di-patch dan tidak ada permukaan serangan seperti panel admin WordPress.
- Konten = file. Semua tulisan adalah Markdown di Git. Riwayat perubahan, rollback, dan review ada gratis.
Di antara SSG, Hugo menang karena satu binary dan build yang sangat cepat. Dengan 100+ catatan Shelves dan 40-an project, hugo --gc tetap selesai dalam hitungan detik. Aku juga tidak butuh ekosistem komponen JavaScript seperti Next.js atau Astro; template Go sudah cukup, dan interaktivitas yang ada (search, filter, dark mode) cukup ditulis dengan vanilla JS kecil.
Tema situs ini ditulis sendiri, bukan tema pihak ketiga. Konsekuensinya semua layout harus dibuat manual, tapi keuntungannya aku paham setiap baris, dan tidak ada CSS atau JS tema yang tidak terpakai.
Struktur Repo Sekilas
.
βββ hugo.toml # konfigurasi Hugo
βββ package.json # Tailwind CLI, hugo-bin, prettier, script dev/build
βββ vercel.json # build di Vercel + versi Hugo yang dipin
βββ assets/css/main.css # sumber Tailwind v4 (@theme, komponen)
βββ static/css/output.css# hasil kompilasi Tailwind (di-commit)
βββ content/ # semua Markdown
βββ data/ # anime.json, catalogs.json
βββ layouts/ # template: _default, partials, per-section
βββ static/ # gambar, ikon, favicon, tema Giscus, demo web
Organisasi Konten
Section utama
Setiap folder level atas di content/ menjadi section Hugo, dan kebanyakan punya layout sendiri di layouts/<section>/:
| Section | URL | Isi |
|---|---|---|
content/blog/ | /blog/ | Tulisan panjang, dikelompokkan per subfolder (Anime, Coding, Food, Opinion, Tech Review, …) |
content/shelves/ | /shelves/ | Catatan teknis, bentuknya seperti file explorer |
content/projects/ | /projects/ | Studi kasus project + demo frontend |
content/journals/ | /journals/ | Jurnal pribadi |
Nama-nama ini hasil redesign besar di September 2026. Pesan commit-nya cukup jelas:
redesign: Tania-style overhaul (sections, About, Shelves, Journals)
- Rename sections: articlesβblog, notesβshelves; merge frontend into projects
- Remove /topics + unused partials/shortcodes
Jadi dulu ada articles, notes, frontend, dan halaman /topics. Sekarang frontend dilebur ke projects: demo frontend adalah project biasa dengan layout = "iframe" yang dirender layouts/projects/iframe.html, sementara file demonya tinggal di static/web-project/<slug>/ dan disajikan apa adanya tanpa diproses Hugo.
Selain section, ada halaman “satuan” dengan type sendiri: /about, /resume, /anime, /wibudenial, /tools, dan lain-lain. Field type di frontmatter memilih folder layout, misalnya type = "wibudenial" memakai layouts/wibudenial/.
Shelves: folder bersarang dengan _index.md
Shelves adalah bagian yang paling “arsitektural”. Strukturnya folder di dalam folder:
content/shelves/
βββ _index.md
βββ Fundamental/
β βββ _index.md
β βββ Docker/
β β βββ _index.md
β β βββ 2026-09-27-docker-network.md
β βββ ...
βββ Deep dives/
β βββ _index.md
β βββ Web/
β βββ _index.md
β βββ 2026-09-27-arsitektur-situs-fanny-dev.md β catatan ini
βββ Off the clock/
βββ Reinventing the wheel/
Kuncinya: setiap folder yang punya _index.md menjadi branch bundle / section bersarang. Tanpa _index.md, Hugo tidak menganggap folder itu section, dan halaman di dalamnya “naik” ke section induk. _index.md tiap folder ditulis dalam YAML dan cukup pendek:
---
title: "Web"
icon: "html.png"
type: "section"
weight: 12
description: "How websites are built, rendered, and shipped, including this one."
---
weightmenentukan urutan folder. Hugo mengurutkan.Sectionsberdasarkan weight dulu, baru judul/tanggal.iconadalah nama file distatic/images/icon/, ikon pixel-art yang juga dipakai di navigasi.- Nama folder boleh mengandung spasi (“Deep dives”). URL-nya tetap rapi (
/shelves/deep-dives/) karena Hugo melakukan urlize pada path.
Layout layouts/shelves/list.html merender daftar folder (.Sections) dan daftar catatan (.RegularPages), plus breadcrumb yang dibangun dari .Ancestors:
{{ range .Ancestors.Reverse }}
{{ if and (hasPrefix .RelPermalink "/shelves/") (ne .RelPermalink "/shelves/") }}
<span aria-hidden="true">/</span>
<a href="{{ .RelPermalink }}">{{ .Title }}</a>
{{ end }}
{{ end }}
.Ancestors mengembalikan rantai induk sampai homepage, jadi perlu difilter agar hanya level di bawah /shelves/ yang tampil.
Konvensi frontmatter
Halaman konten memakai TOML (+++). Contoh frontmatter catatan ini:
+++
title = "Arsitektur Situs fanny.dev (Hugo + Tailwind v4)"
slug = "arsitektur-situs-fanny-dev"
icon = "floppy.png"
date = 2026-09-27T17:30:00+07:00
lastmod = 2026-09-27T17:30:00+07:00
draft = false
toc = true
giscus = true
tags = ["Hugo", "Tailwind CSS", "Static Site", "Architecture", "Vercel"]
+++
Beberapa kebiasaan yang aku pegang:
- Nama file diawali tanggal (
2026-09-27-...md) supaya urut di editor, tapi URL ditentukan olehslug, jadi tanggal tidak bocor ke URL. draft = truemembuat halaman hanya muncul dihugo server -D(scriptnpm run devmemang memakai-D). Build produksi tidak menyertakannya.giscus = truemenyalakan kolom komentar per halaman;toc = truemenandai halaman panjang.- Project punya field tambahan:
tech_stacks,types,featured,project_type,repo,demo,image.
Halaman fragmen: _build
Halaman About merangkai beberapa halaman lain (Now, Career, Tools, More, Social) lewat site.GetPage. Supaya halaman-halaman fragmen itu tidak muncul dua kali (sekali di /about, sekali di URL sendiri), mereka dimatikan dari render dan listing:
[_build]
render = "never"
list = "never"
Kontennya tetap bisa diakses dari template, tapi tidak ada file HTML-nya dan tidak masuk sitemap. Commit itu mengurangi URL di sitemap dari 270 ke 265, yaitu lima URL konten duplikat yang hilang.
Layout Shell
Semua halaman (kecuali yang punya baseof.html sendiri seperti resume dan wibudenial) melewati layouts/_default/baseof.html. Struktur besarnya:
<body class="flex min-h-screen flex-col ...">
header (mobile only) + drawer
<main>
.container-grid
βββ .container-side β sidebar tetap (desktop)
βββ .container-main β konten (760px / 940px)
βββ .container-toc β daftar isi (β₯1360px, opsional)
footer
<body> dibuat flex min-h-screen flex-col dan <main> diberi grow, jadi footer selalu menempel di bawah walaupun kontennya pendek.
Grid dua (atau tiga) kolom
Grid-nya didefinisikan di main.css:
.container-grid {
@apply mx-auto grid px-4;
grid-template-columns: minmax(0, 1fr); /* mobile: satu kolom */
max-width: 86rem;
}
@media (min-width: 1024px) {
.container-grid { grid-template-columns: 260px 1fr; }
}
@media (min-width: 1360px) {
.container-grid.has-toc {
grid-template-columns: 260px minmax(0, 1fr) 244px;
}
}
Di mobile hanya ada satu kolom. Mulai 1024px muncul sidebar 260px. Mulai 1360px, khusus halaman konten tunggal yang punya heading, muncul kolom ketiga untuk daftar isi. Kondisi “punya daftar isi” dicek di template:
{{ $tocSections := slice "blog" "shelves" "journals" "projects" }}
{{ $showToc := and (eq .Kind "page") (in $tocSections .Section)
(ne .Layout "iframe") (in (string .TableOfContents) "href=") }}
Trik in (string .TableOfContents) "href=" cukup kasar tapi efektif: kalau TOC tidak berisi satu link pun, kolomnya tidak dibuat. Heading yang aktif disorot dengan IntersectionObserver kecil di baseof, tanpa library.
Lebar baca: 760 vs 940
Satu hal yang aku tiru dari referensi desain (situs Tania Rascia) adalah lebar konten dibatasi dan diletakkan di tengah track, bukan melebar memenuhi layar:
{{ $narrow := and (eq .Kind "page")
(in (slice "blog" "shelves" "journals" "projects") .Section)
(not (default false .Params.wide)) }}
{{ $cw := cond $narrow "max-w-[760px]" "max-w-[940px]" }}
Halaman baca (artikel, catatan, jurnal, detail project) memakai 760px, kira-kira 70β80 karakter per baris di ukuran font body, nyaman untuk teks panjang. Halaman daftar dan halaman lain memakai 940px supaya grid kartu punya ruang. Ada pintu darurat wide = true di frontmatter kalau satu halaman butuh lebih lebar.
Sidebar tetap dan drawer mobile
Isi sidebar (identitas, bio, navigasi, sosial) ada di satu partial, partials/sidebar-nav.html, yang dirender dua kali: sebagai sidebar desktop dan di dalam drawer mobile. Daftar link navigasinya juga satu sumber di partials/nav-items.html, lengkap dengan logika active state:
{{ $active := or (eq $page.RelPermalink $u)
(eq $page.RelPermalink (printf "%s/" $u))
(and (ne $u "/") (hasPrefix $page.RelPermalink (printf "%s/" $u))) }}
Sidebar desktop memakai position: fixed, bukan sticky. Karena grid-nya di tengah layar, posisi left-nya dihitung supaya tetap sejajar dengan track grid:
.home-sidebar {
position: fixed;
top: 1.5rem;
height: calc(100vh - 3rem);
width: 260px;
left: max(1rem, calc((100vw - 86rem) / 2 + 1rem));
}
Di bawah 1024px, header lg:hidden muncul dengan tombol hamburger. Ada satu jebakan CSS yang sempat membingungkan: header memakai backdrop-blur, dan elemen dengan backdrop-filter menjadi containing block untuk turunan position: fixed. Akibatnya drawer yang ditaruh di dalam header ikut “terkurung” setinggi header. Solusinya, overlay drawer (partials/mobile-nav.html) dirender di level <body>, bukan di dalam header. Komentar di partial itu sengaja ditinggalkan sebagai pengingat.
Pipeline Tailwind v4
@theme sebagai design token
Tailwind v4 tidak lagi memakai tailwind.config.js; konfigurasi pindah ke CSS. Semua token desain ada di blok @theme di assets/css/main.css:
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@plugin "tailwind-scrollbar";
@source "hugo_stats.json";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--font-heading: "Outfit", "Inter", ui-sans-serif, sans-serif;
--color-cream-100: #f8f4e6; /* page background */
--color-ink-900: #202025; /* dark page background */
--color-accent-800: #b26a34; /* primary accent */
--color-accent-400: #e0b877; /* dark-mode accent */
}
Setiap variabel --color-* otomatis menjadi utility: bg-cream-100, text-accent-800, dark:bg-ink-900, dan seterusnya. Palet krem hangat + aksen oker ini menggantikan skala red-* bawaan Tailwind yang dipakai sebelum redesign. Font-nya tiga: Outfit untuk heading, Inter untuk body, JetBrains Mono untuk metadata dan kicker.
Pola yang berulang dijadikan kelas komponen di bagian bawah main.css: .kicker, .post-row, .card-tania, .nav-link, .home-sidebar, .wf-* untuk modal filter, .mnav-* untuk drawer. Aturannya: kalau kombinasi utility yang sama muncul di tiga tempat atau lebih, jadikan kelas.
Dari hugo_stats.json ke output.css
Ini bagian yang paling sering bikin “lho, kok class-ku nggak jalan?”. Alurnya:
layouts/ + content/
β hugo (buildStats = true)
βΌ
hugo_stats.json ββ daftar tag, class, id yang benar-benar muncul di HTML
β @source "hugo_stats.json"
βΌ
Tailwind CLI βββΆ static/css/output.css βββΆ <link> di head.html
Di hugo.toml, buildStats diaktifkan dan file statistiknya di-mount tanpa di-watch supaya tidak memicu rebuild berulang:
[build]
[build.buildStats]
enable = true
[module]
[[module.mounts]]
disableWatch = true
source = 'hugo_stats.json'
target = 'assets/notwatching/hugo_stats.json'
Keuntungan hugo_stats.json dibanding hanya memindai file template: isinya adalah class dari HTML final, termasuk class yang muncul dari Markdown, dari data, atau dari string yang dirangkai di template. Kerugiannya: urutannya penting. Kalau aku menambah utility baru, Hugo harus build dulu supaya hugo_stats.json memuatnya, baru Tailwind dijalankan ulang:
npx hugo --gc
npx @tailwindcss/cli -i ./assets/css/main.css -o ./static/css/output.css
Saat development, npm run dev menjalankan keduanya paralel lewat concurrently (hugo server -D + Tailwind --watch), jadi siklus ini terjadi otomatis.
Kenapa output.css di-commit? Karena itu jaring pengaman. hugo_stats.json ada di .gitignore, dan script build menjalankan Tailwind sebelum Hugo:
"build": "tailwindcss -i ./assets/css/main.css -o ./static/css/output.css --minify && hugo --gc --minify"
Artinya di mesin yang baru meng-clone repo, saat Tailwind jalan, statistik dari Hugo belum ada. Dengan output.css yang selalu di-commit dalam keadaan terbaru, apa yang aku lihat di lokal adalah apa yang ter-deploy. CSS-nya juga disajikan dari static/ sebagai file biasa, bukan lewat Hugo Pipes, jadi tidak ada ketergantungan pada fitur pipeline Hugo tertentu.
Dark Mode
Tailwind v4 secara default memakai media query prefers-color-scheme untuk varian dark:. Aku ingin pengunjung bisa memilih sendiri, jadi variannya diganti menjadi berbasis class:
@custom-variant dark (&:where(.dark, .dark *));
:where() membuat specificity-nya nol, jadi dark: tidak “menang” secara tidak sengaja atas utility lain. Class .dark dipasang di <html> oleh script di partials/button-dark.html:
function currentTheme() {
return (
localStorage.getItem("theme") ||
(window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
);
}
document.querySelectorAll(".theme-toggle").forEach(function (btn) {
btn.addEventListener("click", function () {
var isDark = html.classList.toggle("dark");
localStorage.setItem("theme", isDark ? "dark" : "light");
});
});
Pilihan eksplisit disimpan di localStorage; kalau belum pernah memilih, ikut preferensi sistem. Semua elemen berkelas .theme-toggle (di sidebar desktop dan di header mobile) diikat oleh satu script yang sama. Ikon matahari/bulan di header mobile memakai SVG inline dengan dark:hidden / dark:block, jadi tidak bergantung pada CDN ikon.
Satu catatan jujur: script ini dipanggil di akhir <body>, bukan di <head>. Untuk pengunjung yang memilih dark mode padahal sistemnya light, ada kemungkinan sekilas terlihat tema terang sebelum script jalan (flash of wrong theme). Memindahkan potongan inisialisasi ke <head> adalah perbaikan yang masih ada di daftar.
Taxonomy dan Filter Project di Sisi Klien
Taxonomy yang terdaftar di hugo.toml ada dua:
[taxonomies]
tech_stack = "tech_stacks"
tag = "tags"
tags dipakai untuk chip #tag di catatan dan artikel (masing-masing link ke halaman term), sedangkan tech_stacks untuk project. Field types di project (Frontend, Backend, Server, …) sengaja bukan taxonomy, hanya parameter biasa, karena aku tidak butuh halaman /types/frontend/.
Halaman /projects/ punya filter: pencarian teks + chip type, tag, dan tech stack. Filter ini 100% di sisi klien dan datanya disiapkan Hugo saat build. Pertama, Hugo mengumpulkan semua nilai unik dan menormalkan kuncinya:
{{ range .Params.tech_stacks }}
{{ $k := . | lower | replaceRE "[ .]+" "" }}
{{ if $k }}{{ $techMap = merge $techMap (dict $k .) }}{{ end }}
{{ end }}
“Tailwind CSS” menjadi tailwindcss, “Node.js” menjadi nodejs, jadi aman dipakai sebagai token yang dipisah spasi. Kedua, setiap kartu project membawa atribut data-*:
<a href="{{ .RelPermalink }}" class="wf-card ..."
data-type="{{ delimit $typeKeys " " }}"
data-tags="{{ delimit $tagKeys " " }}"
data-tech="{{ delimit $techKeys " " }}"
data-search="{{ lower (printf "%s %s" .Title .Params.description) }}">
Ketiga, JavaScript kecil mencocokkan: OR di dalam satu dimensi, AND antar dimensi. Memilih React dan Vue berarti “React atau Vue”, tapi memilih React + Backend berarti keduanya harus terpenuhi.
function matchSet(card, attr, set) {
if (set.size === 0) return true;
var vals = (card.getAttribute(attr) || "").split(" ").filter(Boolean);
return vals.some(function (v) { return set.has(v); });
}
Panel filternya satu markup yang tampil sebagai modal di desktop dan bottom sheet di mobile, murni lewat CSS .wf-overlay / .wf-panel. Bisa ditutup dengan klik backdrop atau tombol Escape, dan badge menunjukkan jumlah filter aktif. Untuk 40-an project, memfilter DOM langsung jauh lebih sederhana daripada membuat index JSON terpisah.
Pencarian Shelves
Pencarian di Shelves memakai ide yang sama, dengan satu tambahan: pencarian global dari folder mana pun. Setiap halaman list Shelves merender daftar tersembunyi berisi semua catatan di section shelves:
{{ range (where site.RegularPages "Section" "shelves").ByTitle }}
<a href="{{ .RelPermalink }}" class="shelf-gitem ..."
data-search="{{ lower .Title }} {{ range .Params.tags }}{{ . | lower }} {{ end }}...">
{{ end }}
Saat input kosong, yang tampil adalah isi folder saat ini. Begitu ada ketikan, tampilan folder disembunyikan dan daftar global ditampilkan, difilter dengan indexOf. Setiap hasil juga menampilkan breadcrumb foldernya, jadi kelihatan catatan itu tinggal di mana.
Harganya: setiap halaman folder membawa HTML seluruh daftar catatan. Dengan ~115 catatan, ini masih beberapa puluh KB sebelum kompresi, dan jauh lebih kecil setelah gzip/brotli. Kalau nanti catatannya ribuan, baru masuk akal pindah ke index JSON (misalnya output format khusus) + library seperti Fuse.js atau Pagefind.
Data Remote dan File Data
RSS YouTube saat build
Halaman /wibudenial adalah link-in-bio untuk channel YouTube-ku (ceritanya ada di My YouTube Channel
). Halaman itu menampilkan empat video terbaru, dan datanya diambil saat build, bukan di browser:
{{ $vids := slice }}
{{ with resources.GetRemote "https://www.youtube.com/feeds/videos.xml?channel_id=UCg9xs5203Yyitrqb_y-AAPg" }}
{{ if not .Err }}
{{ $xml := .Content }}
{{ $ids := findRESubmatch `<yt:videoId>([^<]+)</yt:videoId>` $xml }}
{{ $titles := findRESubmatch `<media:title>([^<]+)</media:title>` $xml }}
...
{{ end }}
{{ end }}
Beberapa keputusan di sini:
- Tanpa API key. Feed RSS YouTube publik, jadi tidak ada rahasia yang perlu disimpan di environment Vercel.
- Gagal dengan anggun. Kalau request gagal,
$vidstetap kosong dan bagian video tidak dirender; build tidak ikut gagal. - Regex, bukan parser XML. Aku hanya butuh dua field, dan
findRESubmatchcukup. Kalau butuh lebih banyak,transform.Unmarshalbisa mengubah XML menjadi map. - Konsekuensinya, daftar video hanya segar setiap kali situs di-build ulang. Untuk link-in-bio, itu cukup.
Halaman ini juga punya baseof.html sendiri (layouts/wibudenial/baseof.html) karena tampilannya sama sekali berbeda: kartu terpusat tanpa sidebar. Tapi ia tetap memanggil partials/head.html, jadi font, CSS, dan SEO tetap konsisten.
Daftar anime dari data/
Halaman /anime membaca data/anime.json, yaitu daftar judul, tahun, musim, dan URL gambar (sisa dari project My Anime List
). Hugo otomatis memuat file di data/ ke site.Data:
{{ $anime := site.Data.anime }}
{{ $years := slice }}
{{ range $anime }}{{ $years = $years | append .tahun }}{{ end }}
{{ $years = collections.Reverse (sort ($years | uniq)) }}
Tahun dan musim dikelompokkan saat build, lalu pencarian dan filternya lagi-lagi JavaScript kecil di klien. Pola “Hugo menyiapkan data, JS hanya menyembunyikan/menampilkan” ini konsisten di seluruh situs.
SEO
Semua meta tag terpusat di partials/seo.html, dipanggil dari head.html. Isinya:
- Description dengan fallback bertingkat:
.Descriptionβ.Params.descriptionβ.Summaryβ deskripsi situs, lalu dipotong ke 200 karakter. - Canonical URL, Open Graph, dan Twitter Card. Gambar OG memakai
imagedi frontmatter, fallback ke foto profil. - JSON-LD:
Personuntuk homepage,BlogPostinguntuk halaman konten. Datanya dibangun sebagaidictlalu di-jsonify, jadi escaping aman:
{{- $article := dict
"@context" "https://schema.org"
"@type" "BlogPosting"
"headline" .Title
"datePublished" (.Date.Format "2006-01-02T15:04:05Z07:00")
"dateModified" (.Lastmod.Format "2006-01-02T15:04:05Z07:00")
}}
<script type="application/ld+json">{{ $article | jsonify }}</script>
Karena itu field lastmod di frontmatter benar-benar dipakai: ia masuk ke article:modified_time dan dateModified. robots.txt dibuat dari template (enableRobotsTXT = true) dan menunjuk ke sitemap.xml yang digenerate Hugo.
Ada juga output format khusus untuk resume. Di hugo.toml didefinisikan format ATS (HTML dengan baseName = "ats"), dan halaman resume memakai outputs = ["HTML", "ATS"]. Hasilnya satu sumber data resume menghasilkan dua halaman: versi visual dan versi satu kolom yang ramah ATS (lihat project Website Resume
).
Komentar dengan Giscus
Komentar memakai Giscus, yang menyimpan komentar sebagai GitHub Discussions di repo FannyDevz/Forum. Tidak ada backend dan tidak ada database; pengunjung login dengan GitHub. Partialnya hanya aktif kalau giscus = true, dan memakai data-mapping="pathname" supaya satu URL = satu thread diskusi, serta data-loading="lazy" supaya iframe baru dimuat saat mendekati viewport.
Bagian menariknya adalah tema. Giscus bisa menerima URL file CSS sebagai tema, jadi aku membuat static/giscus.css (krem) dan static/giscus-dark.css (ink gelap) yang meniru token situs. Masalahnya, Giscus berjalan di dalam iframe, jadi class .dark di <html> tidak berpengaruh ke dalamnya. Solusinya: pantau perubahan class dengan MutationObserver, lalu kirim konfigurasi baru lewat postMessage:
function applyGiscusTheme() {
var iframe = document.querySelector("iframe.giscus-frame");
if (!iframe) return;
iframe.contentWindow.postMessage(
{ giscus: { setConfig: { theme: currentGiscusTheme() } } },
"https://giscus.app",
);
}
new MutationObserver(applyGiscusTheme).observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
Tombol dark mode tidak perlu tahu Giscus ada; ia cukup mengubah class, dan observer yang menyesuaikan.
Minifikasi dan Jebakannya
Di hugo.toml ada minifyOutput = true, dan build produksi menjalankan hugo --gc --minify. Minifier Hugo agresif, salah satunya menghapus tanda kutip pada atribut bernilai tunggal. Di source tertulis id="wf-grid", di output menjadi id=wf-grid.
Ini bukan bug (HTML valid), tapi sempat menjebak saat debugging. Perintah seperti curl ... | grep 'id="wf-grid"' tidak menemukan apa-apa, dan aku sempat mengira elemennya tidak dirender. Pelajarannya: saat mencari di HTML hasil build, grep tanpa tanda kutip.
Jebakan lain yang terkait template: nama ikon Iconify mengandung titik dua (material-symbols:search-rounded). Kalau dimasukkan lewat variabel ke atribut icon, html/template Go menganggapnya mirip URL dengan skema mencurigakan dan menggantinya dengan #ZgotmplZ, sehingga ikonnya kosong. Solusinya adalah safeURL:
<iconify-icon icon="{{ .icon | safeURL }}" width="20"></iconify-icon>
String literal langsung di template tidak kena masalah ini; hanya nilai yang lewat variabel.
Deploy di Vercel
Deploy memakai @vercel/static-build, yang menjalankan script build di package.json. Konfigurasinya kecil:
{
"builds": [{ "src": "package.json", "use": "@vercel/static-build" }],
"env": { "HUGO_VERSION": "0.149.0", "HUGO_ENV": "production" },
"redirects": [
{
"source": "/(.*)",
"has": [{ "type": "host", "value": "resume.fanny.dev" }],
"destination": "https://fanny.dev/resume/",
"permanent": true
}
]
}
Dua hal yang layak dicatat:
- Versi Hugo dipin. Hugo cukup sering mengubah perilaku (nama fungsi, default, deprecation). Tanpa pin, build yang kemarin hijau bisa merah hari ini. Tapi perhatikan: di lokal, Hugo datang dari
hugo-bindan versinya 0.116, sedangkan Vercel memakai 0.149. Selisih itu risiko nyata. Fitur yang hanya ada di versi baru bisa lolos di Vercel tapi gagal di lokal, dan sebaliknya. Idealnya kedua versi disamakan. - Redirect subdomain. Dulu resume punya situs terpisah di
resume.fanny.dev. Sekarang resume jadi halaman/resume/, dan subdomain lama diarahkan permanen (301) lewat aturanhas: host, jadi link lama di CV yang sudah tersebar tetap jalan.
Pilihan-Pilihan Performa
Ringkasan keputusan yang membuat situs ini ringan:
- Tanpa framework JS. Semua interaktivitas adalah IIFE vanilla kecil yang ditulis inline di template yang membutuhkannya: filter hanya ada di
/projects/, pencarian hanya di Shelves. - CSS tunggal hasil tree-shaking. Tailwind hanya menghasilkan utility yang benar-benar dipakai (via
hugo_stats.json), lalu di-minify. Seluruh situs memakai satuoutput.csssekitar 100 KB tanpa kompresi, yang di-cache browser setelah kunjungan pertama. - Ikon pixel-art PNG kecil untuk navigasi dan folder, dengan
width/heighteksplisit supaya tidak ada layout shift. Ikon lain lewat web componenticonify-icon. Ikon yang krusial untuk interaksi (hamburger, toggle tema) memakai SVG inline supaya tidak bergantung pada CDN. - Font Google dengan
preconnectdandisplay=swap, dan hanya bobot yang dipakai. - Lazy loading: gambar konten memakai
loading="lazy", Giscus memakaidata-loading="lazy". - Data berat di-fetch saat build, bukan saat runtime: RSS YouTube dan daftar anime tidak menambah request dari browser pengunjung.
hugo --gcmembersihkan cache resource yang tidak terpakai, dan_buildmencegah halaman fragmen dirender sia-sia.
Yang masih bisa diperbaiki: script Iconify dimuat sinkron di <head> dan bisa diberi defer. Font juga bisa di-self-host untuk menghilangkan request ke domain pihak ketiga.
Pelajaran yang Didapat
- Satu sumber untuk satu hal. Sidebar, daftar navigasi, dan SEO masing-masing hanya ada di satu partial. Saat menambah menu atau meta tag, cukup ubah satu file dan desktop maupun mobile ikut berubah.
- Biarkan Hugo menyiapkan data, biarkan JS hanya menampilkan. Filter project, pencarian Shelves, dan daftar anime semuanya memakai pola yang sama: atribut
data-*dirender saat build, dan JS hanya menyalakan/mematikandisplay. Sederhana, cepat, dan mudah di-debug. - Pahami urutan pipeline. Masalah “class Tailwind hilang” hampir selalu karena
hugo_stats.jsonbelum diperbarui. Setelah paham alurnya (Hugo dulu, lalu Tailwind), masalah ini tidak pernah bikin panik lagi. - CSS punya efek samping yang tidak terlihat.
backdrop-filterdiam-diam menjadi containing block untuk elemenfixed. Bug seperti ini tidak kelihatan dari kode HTML-nya; harus tahu spesifikasinya. Hal-hal dasar seperti ini aku kumpulkan di Pengantar Dasar-Dasar CSS . - Output yang di-minify bukan source. Grep tanpa tanda kutip, dan ingat bahwa
html/templatemelakukan sanitasi konteks (#ZgotmplZ). - Pin versi, dan samakan lokal dengan produksi. Pin di Vercel sudah benar, tapi selisih lokal vs produksi (0.116 vs 0.149) masih jadi utang teknis.
- Restrukturisasi itu murah di static site, asal URL dijaga. Mengganti
articlesmenjadiblogdannotesmenjadishelveshanya soal memindah folder. Yang mahal adalah link yang sudah tersebar, jadi redirect (sepertiresume.fanny.dev) danslugeksplisit di frontmatter jadi penting. - Tulis komentar “kenapa” di template. Komentar seperti “harus dirender di level body karena backdrop-filter” di
mobile-nav.htmlmenghemat waktu debugging di masa depan, terutama untuk diri sendiri.
Situs ini tidak akan pernah benar-benar “selesai”. Tapi dengan fondasi seperti ini, menambah section baru, folder Shelves baru (seperti folder Web tempat catatan ini tinggal), atau fitur kecil lain terasa seperti menambah file, bukan membongkar pasang.

Hey! I’m Fanny, the software engineer tending to this digital garden. You can read more about me, or subscribe by email.