Menu docs

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.

Cloudflare D1SQLitemarkdown-itHono

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 + stempel COALESCE menyelesaikan 90% kebutuhan alur editorial tanpa CMS.

Mau fitur seperti ini dibangun untuk bisnismu?