Template yang berisi tanggal dalam bahasa Inggris
Halaman blog tayang. Semua rapi kecuali satu baris di bawah judul:
Mon Aug 18 2025 00:00:00 GMT+0700 (Western Indonesia Time)
Itu hasil dari {{post.published_at}}. Handlebars memanggil toString() pada objek Date yang dikembalikan mysql2, dan hasilnya adalah string internal JavaScript — bukan sesuatu yang layak dibaca pengunjung.
Solusi cepat yang biasanya dipilih: format tanggalnya di route.
const posts = rows.map((r) => ({
...r,
tanggal: r.published_at.toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' }),
}));
Berjalan. Tapi enam bulan kemudian, map semacam itu ada di tujuh route berbeda, tiga di antaranya memakai format yang sedikit berbeda, dan menambah widget "artikel terkait" berarti menyalin map kedelapan. Route berhenti jadi route dan mulai jadi lapisan presentasi.
Handlebars sengaja dibuat logic-less, dan itu sering disalahartikan sebagai "tidak boleh ada fungsi di view". Yang dimaksud sebenarnya: jangan menaruh keputusan bisnis di template. Memformat tanggal bukan keputusan bisnis — itu presentasi, dan tempatnya memang di lapisan view.
Artikel ini membahas sepuluh helper yang dipakai hampir setiap proyek Express + hbs, cara registrasinya terpusat, dan tiga jebakan yang bikin helper diam-diam menghasilkan halaman rusak.
Anatomi helper: inline vs block
Ada dua bentuk, dan membedakannya menghemat banyak kebingungan.
Inline helper mengembalikan nilai. Dipakai seperti variabel:
hbs.registerHelper('upper', (str) => String(str ?? '').toUpperCase());
{{upper post.title}} Block helper menerima badan template dan memutuskan apakah atau berapa kali badan itu dirender. Argumen terakhirnya selalu options, dengan options.fn (badan) dan options.inverse (blok {{else}}):
hbs.registerHelper('ifAny', function (list, options) {
return Array.isArray(list) && list.length ? options.fn(this) : options.inverse(this);
});
{{#ifAny posts}} <ul>{{#each posts}}<li>{{title}}</li>{{/each}}</ul> {{else}} <p>Belum ada artikel.</p> {{/ifAny}} Dua hal yang wajib diingat sejak awal:
- Jangan pakai arrow function pada block helper kalau kamu butuh
this. Arrow function tidak punyathissendiri, danoptions.fn(this)akan kehilangan konteks data. - Argumen terakhir selalu
options, bahkan pada inline helper. Kalau kamu menulis(a, b) => a + bdan template memanggil{{add 1}}, makabbukanundefined—badalah objekoptions. Ini sumber[object Object]yang muncul di halaman.
1–2. Tanggal: formatDate dan relativeDate
Tanpa library. Intl.DateTimeFormat sudah ada di Node sejak lama dan mendukung id-ID secara penuh.
const DATE_STYLES = {
long: { day: 'numeric', month: 'long', year: 'numeric' },
short: { day: 'numeric', month: 'short', year: 'numeric' },
full: { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' },
};
hbs.registerHelper('formatDate', (value, style) => {
const d = value instanceof Date ? value : new Date(value);
if (Number.isNaN(d.getTime())) return '';
const opts = DATE_STYLES[typeof style === 'string' ? style : 'long'] || DATE_STYLES.long;
return new Intl.DateTimeFormat('id-ID', { ...opts, timeZone: 'Asia/Jakarta' }).format(d);
});
{{formatDate post.published_at}} → 18 Agustus 2025 {{formatDate post.published_at "short"}} → 18 Agu 2025
Perhatikan typeof style === 'string'. Tanpa itu, pemanggilan {{formatDate x}} mengirim objek options sebagai style, dan lookup gaya-nya gagal diam-diam.
timeZone juga bukan hiasan. VPS umumnya berjalan di UTC. Artikel yang terbit pukul 07.00 WIB tersimpan sebagai 00:00 UTC, dan tanpa timeZone pembaca melihat tanggal yang mundur satu hari untuk semua posting pagi.
Pasangannya, waktu relatif untuk daftar admin:
const UNITS = [['tahun', 31536000], ['bulan', 2592000], ['hari', 86400], ['jam', 3600], ['menit', 60]];
hbs.registerHelper('relativeDate', (value) => {
const d = value instanceof Date ? value : new Date(value);
if (Number.isNaN(d.getTime())) return '';
const diff = Math.round((Date.now() - d.getTime()) / 1000);
const abs = Math.abs(diff);
if (abs < 60) return 'baru saja';
for (const [label, secs] of UNITS) {
if (abs >= secs) {
const n = Math.floor(abs / secs);
return diff > 0 ? `${n} ${label} lalu` : `dalam ${n} ${label}`;
}
}
return '';
});
Bentuk negatifnya penting untuk artikel terjadwal: dalam 3 jam langsung memberi tahu editor bahwa post itu belum tayang.
3–5. List: limit, range, dan words
limit menghindari mengirim seluruh tabel ke template hanya untuk menampilkan tiga baris:
hbs.registerHelper('limit', (arr, n) => (Array.isArray(arr) ? arr.slice(0, parseInt(n, 10) || 0) : []));
{{#each (limit posts 3)}} <article>{{title}}</article> {{/each}} Tanda kurung itu wajib — itu subexpression. Menulis {{#each limit posts 3}} akan diperlakukan sebagai each dengan tiga argumen dan tidak melakukan apa pun.
range untuk paginasi dan nomor urut:
hbs.registerHelper('range', (start, end) => {
const a = parseInt(start, 10) || 0;
const b = parseInt(end, 10) || 0;
if (b < a || b - a > 1000) return []; // pagar: jangan sampai template membekukan proses
return Array.from({ length: b - a + 1 }, (_, i) => a + i);
});
Batas 1.000 itu bukan paranoia. {{#each (range 1 totalPages)}} dengan totalPages yang tidak sengaja bernilai 900.000 akan menahan event loop selama beberapa detik — dan karena Node satu proses, seluruh situs ikut berhenti merespons.
words untuk memotong teks tanpa memotong di tengah kata:
hbs.registerHelper('words', (text, n) => {
const list = String(text ?? '').replace(/<[^>]*>/g, ' ').split(/\s+/).filter(Boolean);
const count = parseInt(n, 10) || 30;
return list.length <= count ? list.join(' ') : `${list.slice(0, count).join(' ')}…`;
});
Strip tag HTML-nya penting kalau sumbernya konten editor — tanpa itu, <p> dan <strong> ikut terhitung sebagai kata dan ringkasannya jadi terlalu pendek.
6–8. Perbandingan: eq, gt, dan or yang aman
Handlebars sengaja tidak menyediakan operator. Yang dibutuhkan bukan mesin ekspresi, cuma beberapa predikat kecil:
hbs.registerHelper('eq', (a, b) => a === b);
hbs.registerHelper('ne', (a, b) => a !== b);
hbs.registerHelper('gt', (a, b) => Number(a) > Number(b));
hbs.registerHelper('lt', (a, b) => Number(a) < Number(b));
hbs.registerHelper('or', (...args) => args.slice(0, -1).some(Boolean));
hbs.registerHelper('and', (...args) => args.slice(0, -1).every(Boolean));
hbs.registerHelper('not', (a) => !a);
args.slice(0, -1) membuang objek options — inilah alasan or yang ditulis sebagai (a, b) => a || b selalu bernilai benar: options adalah objek, dan objek selalu truthy.
Dipakai bersama #if bawaan:
{{#if (eq post.status "scheduled")}} <span class="badge">Terjadwal {{relativeDate post.published_at}}</span> {{/if}} {{#if (or post.is_featured (gt post.views 1000))}} <span class="badge badge-accent">Populer</span> {{/if}} Perhatikan eq memakai ===. Kalau kamu membandingkan id dari database (number) dengan nilai dari query string (string), {{#if (eq user.id query.id)}} akan selalu false. Pilihannya: normalisasi di route, atau sediakan helper eqLoose terpisah — jangan longgarkan eq, karena perbandingan ketat itu justru yang membuatnya bisa dipercaya.
9. icon: SVG inline tanpa request tambahan
Icon font memaksa satu request tambahan dan satu FOIT. Untuk panel admin, SVG inline lebih murah dan mewarisi currentColor:
const ICONS = require('./icons.generated'); // { 'trash': '<path d="..."/>', ... }
hbs.registerHelper('icon', function (name, options) {
const body = ICONS[name];
if (!body) return ''; // nama salah = tidak ada, bukan halaman rusak
const attrs = (options && options.hash) || {};
const cls = attrs.class || 'size-4';
return new hbs.handlebars.SafeString(
`<svg class="${escapeAttr(cls)}" viewBox="0 0 24 24" fill="none" stroke="currentColor" ` +
`stroke-width="2" stroke-linecap="round" aria-hidden="true">${body}</svg>`
);
});
{{icon "trash" class="size-4 text-red-600"}} Dua hal di sini: options.hash adalah cara membaca argumen bernama, dan SafeString mematikan escaping. SafeString hanya boleh dipakai untuk markup yang kamu susun sendiri. Begitu ada nilai dari database atau dari user di dalamnya, kamu baru saja membuka XSS — karena itu cls di atas tetap melewati escapeAttr.
10. json: menitipkan data ke JavaScript sisi klien
Pola yang sering salah:
<script>const post = {{{jsonRaw post}}};</script>
Kalau post.title berisi </script>, parser HTML menutup blok script lebih awal dan sisa datanya jadi markup. Ini vektor XSS klasik pada template server-rendered.
hbs.registerHelper('json', (value) => new hbs.handlebars.SafeString(
JSON.stringify(value ?? null)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029')
));
Tiga substitusi pertama menutup lubang </script>. Dua terakhir menangani U+2028/U+2029, yang valid di JSON tapi ilegal sebagai literal di JavaScript versi lama — sumber SyntaxError yang hanya muncul pada satu artikel tertentu dan sangat sulit dilacak.
Registrasi terpusat dan pengujiannya
Semua helper di satu file, dan file itu di-require sekali dari app.js:
// utils/hbsutils.js
const hbs = require('hbs');
hbs.registerHelper('formatDate', ...);
// ... sisanya
module.exports = hbs;
// app.js — baris awal, sebelum route apa pun dimount
require('./utils/hbsutils');
Efek sampingnya global dan itu memang yang diinginkan: helper terdaftar untuk setiap res.render, tanpa perlu diimpor per view.
Karena helper adalah fungsi murni, mengujinya tidak butuh server. Cukup Node bawaan:
// test/helpers.test.js — jalankan: node --test
const test = require('node:test');
const assert = require('node:assert');
const hbs = require('../utils/hbsutils');
const call = (name, ...args) => hbs.handlebars.helpers[name](...args, { hash: {} });
test('formatDate memakai zona Jakarta', () => {
assert.strictEqual(call('formatDate', new Date('2025-08-17T17:00:00Z')), '18 Agustus 2025');
});
test('or mengabaikan objek options', () => {
assert.strictEqual(call('or', false, false), false);
});
test('range menolak rentang yang tidak masuk akal', () => {
assert.deepStrictEqual(call('range', 1, 1e6), []);
});
Tes kedua adalah yang paling berharga: itu tepat bug options yang membuat setiap badge "Populer" muncul di semua kartu.
Kapan sebuah helper justru salah tempat
Helper bukan jawaban untuk semuanya. Tiga tanda logika itu seharusnya ada di route atau di lapisan data:
- Butuh akses database atau
await. Handlebars merender secara sinkron; helper async mengembalikanPromise, dan yang muncul di halaman adalah[object Promise]. - Menentukan apa yang boleh dilihat pengguna. Otorisasi adalah keputusan bisnis.
{{#if (canEdit user post)}}menyembunyikan tombol, tapi tidak melindungi endpoint-nya. - Menghitung nilai turunan yang disimpan. Jumlah kata, waktu baca, dan excerpt fallback dihitung sekali saat simpan lalu masuk kolom — bukan dihitung ulang pada setiap render untuk setiap pengunjung.
Aturan singkatnya: helper mengubah bentuk data yang sudah kamu putuskan untuk ditampilkan. Begitu ia mulai memutuskan apa yang ditampilkan, ia sudah pindah ke wilayah route.
Langkah praktis
- Buat
utils/hbsutils.jsbila belum ada,requiredari baris awalapp.js. - Pindahkan setiap
.map()pemformatan yang ada di route ke helper. Cari cepat:grep -rn "toLocaleDateString\|substring(0," routes/. - Tambahkan
eq,gt,or,and,notsekaligus — kelimanya hampir pasti akan terpakai, dan menambahkannya belakangan berarti satu commit lagi. - Audit setiap
{{{ }}}(tiga kurung) di template. Setiap satu yang isinya berasal dari database wajib melewati sanitasi atau helperjsondi atas. - Tulis lima tes
node --testuntuk helper yang paling banyak dipakai. Lima menit, dan menangkap kelas bugoptionsselamanya.



