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>/:

SectionURLIsi
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."
---
  • weight menentukan urutan folder. Hugo mengurutkan .Sections berdasarkan weight dulu, baru judul/tanggal.
  • icon adalah nama file di static/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 oleh slug, jadi tanggal tidak bocor ke URL.
  • draft = true membuat halaman hanya muncul di hugo server -D (script npm run dev memang memakai -D). Build produksi tidak menyertakannya.
  • giscus = true menyalakan kolom komentar per halaman; toc = true menandai 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.

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, $vids tetap kosong dan bagian video tidak dirender; build tidak ikut gagal.
  • Regex, bukan parser XML. Aku hanya butuh dua field, dan findRESubmatch cukup. Kalau butuh lebih banyak, transform.Unmarshal bisa 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 image di frontmatter, fallback ke foto profil.
  • JSON-LD: Person untuk homepage, BlogPosting untuk halaman konten. Datanya dibangun sebagai dict lalu 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:

  1. 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-bin dan 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.
  2. Redirect subdomain. Dulu resume punya situs terpisah di resume.fanny.dev. Sekarang resume jadi halaman /resume/, dan subdomain lama diarahkan permanen (301) lewat aturan has: 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 satu output.css sekitar 100 KB tanpa kompresi, yang di-cache browser setelah kunjungan pertama.
  • Ikon pixel-art PNG kecil untuk navigasi dan folder, dengan width/height eksplisit supaya tidak ada layout shift. Ikon lain lewat web component iconify-icon. Ikon yang krusial untuk interaksi (hamburger, toggle tema) memakai SVG inline supaya tidak bergantung pada CDN.
  • Font Google dengan preconnect dan display=swap, dan hanya bobot yang dipakai.
  • Lazy loading: gambar konten memakai loading="lazy", Giscus memakai data-loading="lazy".
  • Data berat di-fetch saat build, bukan saat runtime: RSS YouTube dan daftar anime tidak menambah request dari browser pengunjung.
  • hugo --gc membersihkan cache resource yang tidak terpakai, dan _build mencegah 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

  1. 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.
  2. 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/mematikan display. Sederhana, cepat, dan mudah di-debug.
  3. Pahami urutan pipeline. Masalah “class Tailwind hilang” hampir selalu karena hugo_stats.json belum diperbarui. Setelah paham alurnya (Hugo dulu, lalu Tailwind), masalah ini tidak pernah bikin panik lagi.
  4. CSS punya efek samping yang tidak terlihat. backdrop-filter diam-diam menjadi containing block untuk elemen fixed. Bug seperti ini tidak kelihatan dari kode HTML-nya; harus tahu spesifikasinya. Hal-hal dasar seperti ini aku kumpulkan di Pengantar Dasar-Dasar CSS .
  5. Output yang di-minify bukan source. Grep tanpa tanda kutip, dan ingat bahwa html/template melakukan sanitasi konteks (#ZgotmplZ).
  6. 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.
  7. Restrukturisasi itu murah di static site, asal URL dijaga. Mengganti articles menjadi blog dan notes menjadi shelves hanya soal memindah folder. Yang mahal adalah link yang sudah tersebar, jadi redirect (seperti resume.fanny.dev) dan slug eksplisit di frontmatter jadi penting.
  8. Tulis komentar “kenapa” di template. Komentar seperti “harus dirender di level body karena backdrop-filter” di mobile-nav.html menghemat 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.

Comments