Fitur & Build Notes · · 3 menit baca
Blog tanpa CMS: D1 + markdown-it di Cloudflare Workers
Bedah blog situs ini: render markdown sekali saat simpan (bukan per request), prepared statement D1, stempel publish COALESCE yang menjaga urutan RSS, dan seed draft dari file markdown.
Blog situs ini tidak memakai CMS: post disimpan di Cloudflare D1 (SQLite di edge), ditulis dalam markdown lewat panel admin, dan dirender jadi HTML sekali saat disimpan — bukan setiap request. Ini bedah lengkapnya.
Keputusan kunci: render markdown saat simpan, bukan saat baca
Admin menulis markdown ──▶ render sekali ──▶ simpan KEDUANYA (md + html) di D1
Pengunjung membuka post ──▶ sajikan html jadi ── nol kerja parsing per request
Kolomnya berdampingan di tabel: content_md (untuk diedit lagi) dan content_html (untuk disajikan). Trade-off-nya cuma satu — kalau pipeline markdown berubah, post lama perlu di-render ulang — dan itu jauh lebih murah daripada mem-parse markdown di setiap page view.
1. Pipeline markdown
// src/lib/markdown.ts
import MarkdownIt from "markdown-it";
import taskLists from "markdown-it-task-lists";
const md = new MarkdownIt({
html: false, // HTML mentah di-escape — konten admin pun tidak dipercaya
breaks: true, // enter = <br>, cara penulis non-teknis berpikir
linkify: true, // URL telanjang jadi link
typographer: true, // kutip lurus jadi kutip keriting, -- jadi em-dash
}).use(taskLists);
export const renderMarkdown = (source: string): string => md.render(source);
html: false adalah keputusan keamanan: sekalipun akun admin bocor, penyerang tidak bisa menyuntik <script> lewat konten post.
2. Query D1: prepared statement + binding
// src/db/posts.ts
export async function listPublished(db: D1Database, limit: number): Promise<Post[]> {
const { results } = await db
.prepare(`SELECT ${POST_COLS} FROM posts WHERE status = 'published'
ORDER BY published_at DESC LIMIT ?`)
.bind(limit)
.all<PostRow>();
return results.map(fromRow);
}
Selalu .bind(?) — tidak pernah interpolasi string ke SQL. D1 adalah SQLite biasa, jadi semua intuisi SQL-mu langsung terpakai.
3. Detail kecil yang penting: stempel publish pertama
published_at hanya diisi sekali — unpublish lalu publish ulang tidak mengubah tanggal terbit (dan tidak merusak urutan RSS):
export async function setStatus(db: D1Database, id: number, status: string) {
const now = new Date().toISOString();
if (status === "published") {
await db.prepare(
`UPDATE posts SET status = 'published',
published_at = COALESCE(published_at, ?), -- isi hanya jika masih NULL
updated_at = ? WHERE id = ?`,
).bind(now, now, id).run();
}
}
4. Seed saat database kosong
Saat DB masih kosong, empat artikel contoh dimasukkan sebagai draft — situs baru tidak pernah tampil kosong, tapi tidak ada konten yang tayang tanpa persetujuan admin. Artikelnya file markdown biasa yang di-import sebagai text module oleh Wrangler:
// wrangler.jsonc — file .md bisa di-import seperti modul
"rules": [{ "type": "Text", "globs": ["**/*.md"], "fallthrough": true }]
import artikel from "../../content/seed/what-rag-actually-is.md"; // string!
Pelajaran
- SQLite/D1 lebih dari cukup untuk blog + admin solo — jangan mulai dari Postgres terkelola kalau bebannya baca-ringan.
- Simpan sumber (md) dan hasil (html) berdampingan; jangan pilih salah satu.
- Status
draft/published+ stempelCOALESCEmenyelesaikan 90% kebutuhan alur editorial tanpa CMS.
Mau fitur seperti ini dibangun untuk bisnismu?