Như đã chia sẻ trong bài viết khoe chuyển nhà qua Astro, một trong những lo ngại lớn nhất của mình khi chuyển qua dùng static site là làm sao vận hành hệ thống bình luận (comment) cho blog: sử dụng Giscus, Disqus, Cusdis, hay tự xây dụng hệ thống riêng.
Trong series này, mình sẽ chia sẻ toàn bộ quá trình xây dựng hệ thống comment cho blog thuanbui.me.
Trong Phần 1 hôm nay, mình sẽ tập trung vào việc xây dựng nền tảng cơ bản: database, API và UI để hiển thị / gửi comment.
Hy vọng bạn sẽ tìm được thông tin hữu ích để áp dụng cho blog hoặc dự án của mình.
I. Các lựa chọn làm hệ thống comment
Sau nhiều vòng tìm hiểu, mình tổng kết được danh sách các giải pháp để thiét lập hệ thống comment cho blog:
- Disqus
- Giscus
- Cusdis
- AstroDB + Turso
Disqus là lựa chọn nhanh và đơn giản nhất để tích hợp. Tuy nhiên, nó đi kèm quảng cáo và tracking — trong khi mình đang muốn loại bỏ toàn bộ quảng cáo ra khỏi blog, nên loại ngay Disqus ra khỏi danh sách.
Giscus là một giải pháp rất phổ biến trong cộng đồng static site. Nó tận dụng GitHub Discussions để lưu trữ comment, khá tiện lợi và miễn phí. Tuy nhiên, có hai điểm mình chưa thực sự phù hợp:
- Người dùng bắt buộc phải có tài khoản GitHub mới comment được
- Việc chuyển comment cũ từ WordPress sẽ rất khó khăn vì chưa có sẵn công cụ hỗ trợ.
AstroDB + Tursob cũng là một lựa chọn đáng cân nhắc nếu muốn tự quản lý comment. AstroDB giúp tích hợp database vào Astro, còn Turso cung cấp database chạy ở edge, khá nhanh và hiện đại. Tuy nhiên, mình sẽ cần phải quản lý thêm dịch vụ database từ Turso.
Cusdis thì gọn nhẹ, đơn giản, và có thể tự cài đặt lên VPS cá nhân. Đây là lựa chọn mình khá thích vì cài đặt đơn giản, giao diện dễ sử dụng. Tuy nhiên, nếu phải tự self-host thì mình lại phải cần quản lý VPS - đi ngược lại mục đích ban đầu khi chuyển blog qua Astro: không phải quản lý server nữa.
II. Vì sao mình chọn Cloudflare D1 + Drizzle?
Sau khi cân nhắc các lựa chọn, mình quyết định tự xây dựng một hệ thống comment riêng để có thể kiểm soát hoàn toàn dữ liệu và trải nghiệm người dùng.
Ban đầu, mình nghiêng về hướng sử dụng AstroDB + Turso. Tuy nhiên, blog của mình hiện đang chạy trên Cloudflare Workers, nên mình muốn mọi thứ được gom về cùng một hệ sinh thái cho đơn giản và đồng bộ.
Thực tế, mình đamg dùng Cloudflare cho hầu hết các phần của blog:
- Workers để chạy Astro site
- R2 để lưu trữ ảnh
- DNS để quản lý domain
- Zero Trust để bảo vệ các trang quan trọng
Với nhu cầu blog cá nhân, toàn bộ hệ thống này nằm trong giới hạn free tier của Cloudflare, nên không tốn bất kỳ chi phí nào để duy trì blog.
Vì sao thêm Drizzle ORM?
AstroDB (dùng Turso/libSQL) hiện chưa hỗ trợ Cloudflare D1, nên không thể dùng trong hệ thống này.
Vì vậy, mình chọn Drizzle ORM — đơn giản vì nó tương tự với Eloquent của Laravel, mà mình đã quen sử dụng.
- Cách viết query rõ ràng, dễ đọc
- Có migration để quản lý schema
- Có Drizzle Studio để xem dữ liệu trực quan
Nhờ đó, mình không phải làm việc trực tiếp với raw SQL query, và vẫn giữ được workflow quen thuộc khi làm việc với database.
Tổng kết hướng tiếp cận
Giải pháp cuối cùng:
- Cloudflare Workers chạy Astro app + API
- Cloudflare D1 làm database
- Drizzle ORM quản lý schema & migration
- Svelte island để render comment phía client
Ưu điểm:
- Không cần quản lý VPS
- Không phụ thuộc bên thứ ba
- Không cần đăng ký tài khoản để comment
- Dễ migrate từ WordPress
UI phần comment ở front end được sao chép từ Giscus còn UI quản lý comment ở backend được lấy cảm hứng từ Cusdis.
Kiến trúc tổng thể
Astro Blog (Cloudflare Workers) ├── src/pages/api/comments/[slug].ts ← API endpoint (GET + POST) ├── src/components/CommentsIsland.svelte ← Svelte island (list + form) └── src/db/schema.ts ← Drizzle schema
Cloudflare D1 (SQLite serverless) └── database: blog-thuanbui-commentsIII. Cài Đặt Cloudflare D1
1. Tạo D1 database
Đầu tiên, mình tạo database mới trên Cloudflare D1 bằng wrangler:
# Đăng nhập wranglernpx wrangler login
# Tạo database productionnpx wrangler d1 create blog-thuanbui-commentsOutput sẽ trả về:
⛅️ wrangler 4.76.0 (update available 4.77.0)─────────────────────────────────────────────✅ Successfully created DB 'blog-thuanbui-comments' in region APACCreated your new D1 database.
To access your new D1 Database in your Worker, add the following snippet to your configuration file:{ "d1_databases": [ { "binding": "blog_thuanbui_comments", "database_name": "blog-thuanbui-comments", "database_id": "7ffe7eee-9d2b-4958-befd-2404d710d903" } ]}✔ Would you like Wrangler to add it on your behalf? … yes✔ What binding name would you like to use? … COMMENTS_DB✔ For local dev, do you want to connect to the remote resource instead of a local resource? … noSau khi tạo xong, nhớ copy lại database_id để dùng cho bước tiếp theo.
2. Cập nhật wrangler.jsonc
Thêm binding D1 vào file wrangler.jsonc hiện tại:
{ "main": "@astrojs/cloudflare/entrypoints/server", "name": "blog-thuanbui", "compatibility_flags": ["nodejs_compat"], "compatibility_date": "2026-03-17", "assets": { "directory": "./dist/client", "not_found_handling": "404-page", }, "d1_databases": [ { "binding": "COMMENTS_DB", "database_name": "blog-thuanbui-comments", "database_id": "7ffe7eee-9d2b-4958-befd-2404d710d903", }, ],}3. Generate types từ wrangler config
npx wrangler typesLệnh này sẽ tạo ra file worker-configuration.d.ts ở root project — cần thiết để Cloudflare Workers build thành công.
Cần phải chạy lại lệnh này mỗi khi bổ sung thông số vào file wrangler.jsonc.
4. Cài đặt thêm các packages cần thiết
# Cloudflare adapterpnpm add @astrojs/cloudflare
# Types cho Node built-inspnpm add -D @types/node5. Cấu hình astro.config.ts
Bổ sung thêm khai báo cho Cloudflare adapter
import { defineConfig } from "astro/config";import cloudflare from "@astrojs/cloudflare";
export default defineConfig({ output: "server", adapter: cloudflare({ persistState: true, // giữ local D1 data giữa các lần restart }),});IV. Cấu trúc database
Mình thiết kế database cho hệ thống comment dựa trên cấu trúc quen thuộc từ WordPress gồm 2 table chính
comments— lưu toàn bộ commenttrusted_emails— lưu các email đã được duyệt
1. Table comments
Đây là table chính, lưu toàn bộ nội dung comment, gồm các cột chính như sau:
postSlug: xác định comment thuộc bài viết nàostatus: quản lý trạng thái (pending,approved,deleted)parentId: hỗ trợ reply (nested comment)createdAt: lưu dạng ISO string
Ngoài ra còn có thêm các cột để phục vụ cho nhu cầu mở rộng sau này:
discordMessageId: dùng để update message trên Discord khi duyệt/xóa commentwpCommentId,wpParentId: dùng khi chuyển comment cũ từ WordPress qua
2. Tabale trusted_emails
Table này sẽ lưu lại tất cả các email từ các bình luận đã có trên blog, nhằm tối ưu trải nghiệm cho blog
- Email đã được duyệt một lần → các comment sau sẽ tự động được duyệt
- Bình luận với email mới chưa có trong bảng này → cần phải được duyệt mới hiện ra trên blog
V. Cài Đặt Drizzle ORM
1. Cài packages
# Drizzlepnpm add drizzle-ormpnpm add -D drizzle-kitSau khi cài, kiểm tra file package.json xem đủ các packages sau chưa:
{ "dependencies": { "drizzle-orm": "latest", "@astrojs/cloudflare": "latest", "@astrojs/svelte": "latest" }, "devDependencies": { "drizzle-kit": "latest", "@types/node": "latest" }}2. Tạo schema
Tạo file src/db/schema.ts:
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
export const comments = sqliteTable("comments", { id: integer("id").primaryKey({ autoIncrement: true }),
// Related post postSlug: text("post_slug").notNull(),
// Commenter info authorName: text("author_name").notNull(), authorEmail: text("author_email").notNull(), authorUrl: text("author_url"), // optional website
// Content content: text("content").notNull(),
// Timestamp (ISO 8601 string since D1/SQLite has no native datetime) createdAt: text("created_at").notNull(),
// Approval status (0 = pending, 1 = approved, 2 = deleted) // Default 0 — trusted emails are set to 1 directly in POST handler status: integer("status").notNull().default(0),
// Reply support (nested comments, null = top-level) parentId: integer("parent_id"),
// Discord message ID to update embed after approve/delete discordMessageId: text("discord_message_id"),
// Original WordPress comment ID — only used when migrating from WP XML to resolve parent_id // After migration, this column is no longer used but kept for reference wpCommentId: integer("wp_comment_id"),
// WordPress parent comment ID — used during WP XML migration to resolve nested comments // After migration, this column is no longer used but kept for reference wpParentId: integer("wp_parent_id"),});
// Emails approved once → automatically approved for subsequent commentsexport const trustedEmails = sqliteTable("trusted_emails", { id: integer("id").primaryKey({ autoIncrement: true }), email: text("email").notNull().unique(), addedAt: text("added_at").notNull(),});
export type Comment = typeof comments.$inferSelect;export type NewComment = typeof comments.$inferInsert;3. Cấu hình Drizzle Kit
Tạo file drizzle.config.ts ở root project:
import type { Config } from "drizzle-kit";
const { LOCAL_DB_PATH, DB_ID, D1_TOKEN, CF_ACCOUNT_ID } = process.env;
export default LOCAL_DB_PATH ? ({ // Local dev with wrangler schema: "./src/db/schema.ts", out: "./migrations", dialect: "sqlite", dbCredentials: { url: LOCAL_DB_PATH, }, } satisfies Config) : ({ // Push migrations to D1 production schema: "./src/db/schema.ts", out: "./migrations", dialect: "sqlite", driver: "d1-http", dbCredentials: { databaseId: DB_ID!, token: D1_TOKEN!, accountId: CF_ACCOUNT_ID!, }, } satisfies Config);4. Thêm scripts vào package.json
Cập nhật file package.json để generate và apply migration nhanh hơn:
{ "scripts": { "dev": "astro dev", "build": "astro build",
"db:generate": "drizzle-kit generate", "db:migrate:local": "wrangler d1 migrations apply blog-thuanbui-comments --local", "db:migrate:prod": "wrangler d1 migrations apply blog-thuanbui-comments --remote", "db:studio:local": "LOCAL_DB_PATH=$(find .wrangler/state/v3/d1/miniflare-D1DatabaseObject -type f -name '*.sqlite' -print -quit) drizzle-kit studio" }}5. Tạo database
# 1. Generate SQL migration from schemapnpm run db:generate
# 2. Apply to local D1 (for local development)pnpm run db:migrate:local
# 3. Apply to D1 productionpnpm run db:migrate:prodVI. Tạo API Endpoints
Mình tạo API endpoint ở src/pages/api/comments/[slug].ts để GET/POST comment.
import type { APIContext } from "astro";import { drizzle } from "drizzle-orm/d1";import { eq, and, desc } from "drizzle-orm";import { env } from "cloudflare:workers";import { comments, trustedEmails } from "../../../db/schema";
export const prerender = false;
// ─── GET: Lấy danh sách comment của một bài ────────────────────────────────export async function GET({ params }: APIContext) { const { slug } = params; if (!slug) { return new Response(JSON.stringify({ error: "Missing slug" }), { status: 400, headers: { "Content-Type": "application/json" }, }); }
try { const db = drizzle(env.COMMENTS_DB, { schema: { comments } });
const result = await db .select({ id: comments.id, authorName: comments.authorName, authorUrl: comments.authorUrl, content: comments.content, createdAt: comments.createdAt, parentId: comments.parentId, }) .from(comments) .where(and(eq(comments.postSlug, slug), eq(comments.status, 1))) .orderBy(desc(comments.createdAt));
return new Response(JSON.stringify({ comments: result }), { status: 200, headers: { "Content-Type": "application/json" }, }); } catch (error) { console.error("GET comments error:", error); return new Response(JSON.stringify({ error: "Database error" }), { status: 500, headers: { "Content-Type": "application/json" }, }); }}
// ─── POST: Thêm comment mới ────────────────────────────────────────────────export async function POST({ params, request }: APIContext) { const { slug } = params; if (!slug) { return new Response(JSON.stringify({ error: "Missing slug" }), { status: 400, headers: { "Content-Type": "application/json" }, }); }
let body: { authorName?: string; authorEmail?: string; authorUrl?: string; content?: string; parentId?: number; };
try { body = await request.json(); } catch { return new Response(JSON.stringify({ error: "Invalid JSON" }), { status: 400, headers: { "Content-Type": "application/json" }, }); }
const { authorName, authorEmail, content, authorUrl, parentId } = body;
if (!authorName?.trim() || !authorEmail?.trim() || !content?.trim()) { return new Response( JSON.stringify({ error: "Tên, email và nội dung là bắt buộc" }), { status: 400, headers: { "Content-Type": "application/json" } } ); }
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(authorEmail)) { return new Response(JSON.stringify({ error: "Email không hợp lệ" }), { status: 400, headers: { "Content-Type": "application/json" }, }); }
if (content.trim().length > 5000) { return new Response( JSON.stringify({ error: "Comment quá dài (tối đa 5000 ký tự)" }), { status: 400, headers: { "Content-Type": "application/json" }, } ); }
try { const db = drizzle(env.COMMENTS_DB); const normalizedEmail = authorEmail.trim().toLowerCase();
// ── Kiểm tra email đã trusted chưa ────────────────────────────────── const trusted = await db .select() .from(trustedEmails) .where(eq(trustedEmails.email, normalizedEmail)) .limit(1);
const isTrusted = trusted.length > 0; const initialStatus = isTrusted ? 1 : 0;
// ── Insert comment ─────────────────────────────────────────────────── await db .insert(comments) .values({ postSlug: slug, authorName: authorName.trim().slice(0, 100), authorEmail: normalizedEmail, authorUrl: authorUrl?.trim() || null, content: content.trim(), createdAt: new Date().toISOString(), status: initialStatus, parentId: parentId || null, }) .returning();
// ── Gửi Discord notification nếu pending (xem supplement guide) ───── // if (!isTrusted) { await sendCommentNotification(...) }
return new Response( JSON.stringify({ success: true, pending: !isTrusted, message: isTrusted ? "Bình luận đã được đăng." : "Bình luận của bạn đang chờ duyệt.", }), { status: 201, headers: { "Content-Type": "application/json" } } ); } catch (error) { console.error("POST comment error:", error); return new Response(JSON.stringify({ error: "Database error" }), { status: 500, headers: { "Content-Type": "application/json" }, }); }}API sẽ hoạt động như sau:
- Comment từ email lần đầu xuất hiện trên blog (không có trong bảng trusted_emails) sẽ có
status=0(pending). - Comment từ các email đã được duyệt trước đó sẽ tự động có
status=1và hiện ra ngay.
VII. Tạo UI Components — Svelte Island
Thay vì render comment server-side (bắt buộc phải sử dụng tính năng SSR phức tạp), mình dùng Svelte island để comment có thể load trên client-side. Nội dung blog vẫn hoàn toàn static. Component này tự fetch comment và gửi form qua API.
<CommentsIsland {slug} client:visible />Cấu trúc sẽ như sau
Blog post (prerender = true, static HTML) └── <CommentsIsland slug={slug} client:load /> ├── onMount → fetch GET /api/comments/slug → render list └── submit form → POST /api/comments/slug1. Cài đặt Svelte
npx astro add svelteCập nhật file astro.config.ts
import { defineConfig } from "astro/config";import cloudflare from "@astrojs/cloudflare";import svelte from "@astrojs/svelte";
export default defineConfig({ output: "server", adapter: cloudflare({ persistState: true, // giữ local D1 data giữa các lần restart }), integrations: [svelte()],});B2. Tạo Svelte components
Tạo file CommetnsIsland.svelte trong thư mục src/components/ với nội dung như sau
<script lang="ts"> import { onMount } from "svelte"; import CommentItem from "./CommentItem.svelte";
// Props — Svelte 5 runes let { slug }: { slug: string } = $props();
// ── Types ────────────────────────────────────────────────────────────── interface Comment { id: number; authorName: string; authorUrl: string | null; content: string; createdAt: string; parentId: number | null; }
type FormStatus = "idle" | "loading" | "success" | "pending" | "error";
// ── State ────────────────────────────────────────────────────────────── let comments = $state<Comment[]>([]); let loadError = $state(false); let formStatus = $state<FormStatus>("idle"); let formError = $state(""); let replyTo = $state<{ id: number; name: string } | null>(null);
let form = $state({ authorName: "", authorEmail: "", authorUrl: "", content: "", });
// ── Load comments on mount ───────────────────────────────────────────── onMount(async () => { try { const res = await fetch(`/api/comments/${encodeURIComponent(slug)}`); if (!res.ok) throw new Error("Failed to load comments"); const data = (await res.json()) as { comments?: Comment[] }; comments = data.comments ?? []; } catch { loadError = true; } });
// ── Precomputed grouped structure: parentId -> children (avoids O(n²) filtering) ── let grouped = $derived(() => { const map = new Map<number | null, Comment[]>(); for (const c of comments) { const key = c.parentId; if (!map.has(key)) map.set(key, []); map.get(key)!.push(c); } return map; }); let topLevel = $derived(grouped().get(null) ?? []);
// ── Helpers ──────────────────────────────────────────────────────────── function formatDate(iso: string) { return new Date(iso).toLocaleDateString("vi-VN", { year: "numeric", month: "long", day: "numeric", }); }
function repliesFor(parentId: number) { return grouped().get(parentId) ?? []; }
/** Defense-in-depth: only render href for safe http/https URLs */ function isSafeUrl(url: string | null): boolean { if (!url) return false; try { const parsed = new URL(url); return parsed.protocol === "http:" || parsed.protocol === "https:"; } catch { return false; } }
// ── Submit ───────────────────────────────────────────────────────────── async function handleSubmit(e: SubmitEvent) { e.preventDefault(); formStatus = "loading"; formError = "";
try { const res = await fetch(`/api/comments/${slug}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...form, parentId: replyTo?.id ?? null, }), });
const data = (await res.json()) as { error?: string; success?: boolean; pending?: boolean; message?: string; }; if (!res.ok) throw new Error(data.error || "Something went wrong");
formStatus = data.pending ? "pending" : "success"; form = { authorName: "", authorEmail: "", authorUrl: "", content: "" }; replyTo = null;
// Reload comments if approved immediately if (!data.pending) { const refresh = await fetch(`/api/comments/${encodeURIComponent(slug)}`); if (refresh.ok) { const refreshData = (await refresh.json()) as { comments?: Comment[]; }; comments = refreshData.comments ?? []; } formStatus = "idle"; } } catch (err) { formStatus = "error"; formError = err instanceof Error ? err.message : "Something went wrong"; } }</script>
<!-- ── Comment list ──────────────────────────────────────────────────────── --><section id="comments" class="mt-12 border-t border-border pt-8"> {#if loadError} <p class="text-sm text-muted-foreground">Unable to load comments.</p> {:else if comments.length === 0} <p class="text-sm text-muted-foreground"> No comments yet. Be the first to comment! </p> {:else} <h2 class="mb-6 text-xl font-bold">{comments.length} Comments</h2> <ol class="list-none p-0 space-y-0"> {#each topLevel as comment (comment.id)} <CommentItem {comment} {repliesFor} {replyTo} onReply={(id, name) => { replyTo = { id, name }; document.getElementById("comment-form")?.scrollIntoView({ behavior: "smooth", block: "start" }); }} {formatDate} {isSafeUrl} /> {/each} </ol> {/if}
<!-- ── Form ──────────────────────────────────────────────────────────── --> <div id="comment-form" class="mt-10"> <h3 class="mb-4 text-lg font-bold"> {replyTo ? `Reply to ${replyTo.name}` : "Leave a Comment"} </h3>
{#if replyTo} <div class="mb-4 flex items-center gap-3 rounded border border-border bg-muted/30 px-4 py-2 text-sm" > <span class="text-muted-foreground" >Replying to <strong>{replyTo.name}</strong></span > <button onclick={() => (replyTo = null)} class="ml-auto text-xs text-muted-foreground hover:text-foreground bg-transparent border-none cursor-pointer" > Cancel </button> </div> {/if}
{#if formStatus === "pending"} <div class="rounded border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300" > ⏳ Your comment is pending approval. It will be visible once approved. </div> {:else} {#if formStatus === "error"} <div class="mb-4 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-300" > ❌ {formError} </div> {/if}
<form onsubmit={handleSubmit} novalidate class="space-y-4"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div class="flex flex-col gap-1"> <label for="authorName" class="text-sm font-medium"> Name <span class="text-red-500">*</span> </label> <input id="authorName" type="text" bind:value={form.authorName} required maxlength="100" placeholder="Thuan Bui" disabled={formStatus === "loading"} class="rounded border border-border bg-background px-3 py-2 text-sm focus:outline-2 focus:outline-accent disabled:opacity-50" /> </div> <div class="flex flex-col gap-1"> <label for="authorEmail" class="text-sm font-medium"> Email <span class="text-red-500">*</span> </label> <input id="authorEmail" type="email" bind:value={form.authorEmail} required placeholder="email@example.com" disabled={formStatus === "loading"} class="rounded border border-border bg-background px-3 py-2 text-sm focus:outline-2 focus:outline-accent disabled:opacity-50" /> <small class="text-xs text-muted-foreground" >Not publicly displayed</small > </div> </div>
<div class="flex flex-col gap-1"> <label for="authorUrl" class="text-sm font-medium"> Website <span class="text-muted-foreground">(optional)</span> </label> <input id="authorUrl" type="url" bind:value={form.authorUrl} placeholder="https://example.com" disabled={formStatus === "loading"} class="rounded border border-border bg-background px-3 py-2 text-sm focus:outline-2 focus:outline-accent disabled:opacity-50" /> </div>
<div class="flex flex-col gap-1"> <label for="content" class="text-sm font-medium"> Comment <span class="text-red-500">*</span> </label> <textarea id="content" bind:value={form.content} required rows="5" maxlength="5000" placeholder="Share your thoughts..." disabled={formStatus === "loading"} class="resize-y rounded border border-border bg-background px-3 py-2 text-sm focus:outline-2 focus:outline-accent disabled:opacity-50" ></textarea> </div>
<button type="submit" disabled={formStatus === "loading"} class="rounded bg-accent px-5 py-2 text-sm font-semibold text-background hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50" > {formStatus === "loading" ? "Submitting..." : "Post Comment"} </button> </form> {/if} </div></section>Tạo thêm file CommentItem.svelte trong cùng thư mục
<script lang="ts"> import CommentItem from "./CommentItem.svelte";
interface Comment { id: number; authorName: string; authorUrl: string | null; content: string; createdAt: string; parentId: number | null; }
let { comment, repliesFor, replyTo, onReply, formatDate, isSafeUrl, }: { comment: Comment; repliesFor: (parentId: number) => Comment[]; replyTo: { id: number; name: string } | null; onReply: (id: number, name: string) => void; formatDate: (iso: string) => string; isSafeUrl: (url: string | null) => boolean; } = $props();
let children = $derived(repliesFor(comment.id));</script>
<li id="comment-{comment.id}" class={!comment.parentId ? "pt-6" : ""}> <div class="flex items-center gap-3 mb-2 text-sm text-muted-foreground"> <strong class="text-foreground font-semibold"> {#if isSafeUrl(comment.authorUrl)} <a href={comment.authorUrl} rel="noopener noreferrer" target="_blank" class="text-accent no-underline hover:underline" > {comment.authorName} </a> {:else} {comment.authorName} {/if} </strong> <time datetime={comment.createdAt}>{formatDate(comment.createdAt)}</time> </div> <p class="text-sm leading-relaxed text-muted-foreground break-words overflow-wrap-anywhere">{comment.content}</p>
<button onclick={() => onReply(comment.id, comment.authorName)} class="mt-1 text-xs text-muted-foreground hover:text-foreground cursor-pointer bg-transparent border-none p-0" > Reply </button>
<!-- Recursive replies --> {#if children.length > 0} <ol class="mt-4 list-none border-l-2 border-border pl-4 space-y-4"> {#each children as child (child.id)} <CommentItem comment={child} {repliesFor} {replyTo} {onReply} {formatDate} {isSafeUrl} /> {/each} </ol> {/if}</li>3. Tích hợp vào blog post layout
Cập nhật file layout đang dùng để hiển thị nội dung bài viết và bổ sung khai báo cho phần comment vào vị trí tương ứng. Chẳng hạn nếu bạn dùng theme Astro Paper thì cần chỉnh sửa file PostDetails.astro
import CommentsIsland from "../components/CommentsIsland.svelte"
<article> <slot /></article><!-- Island load sau khi page render — blog vẫn là static --><CommentsIsland {slug} client:load />VIII. Thử nghiệm trên local
Tạo database trên local bằng lệnh sau
# Apply migration vào local D1 trướcpnpm db:migrate:localChạy lệnh sau để build và kiểm tra
pnpm run build && npx astro previewKết quả trả về
Using secrets defined in dist/server/.dev.vars18:24:04 [@astrojs/cloudflare] astro v13.1.4 ready in 29 ms
┃ Local http://localhost:4321/Kiểm tra lại bài viết ở theo link http://localhost:4321/, bạn sẽ thấy form comment sẽ hiện ra bên dưới mỗi bài viết.
IX: Chuyển Comment từ WordPress XML qua D1
Mình sử dụng script để nhập toàn bộ comment từ file XML export của WordPress vào D1. Script này tự động cập nhật thông số parent_id cho từng comment, đồng thời cập nhật luôn vào trusted_emails.
1. Xuất WordPress comments
Truy cập vào WordPress Dashboard: Tools → Export → All content → tải về file XML
2. Tạo script xử lý comment
Tạo file scripts/import-wp-comments.mjs:
#!/usr/bin/env node// scripts/import-wp-comments.mjs// Run: node scripts/import-wp-comments.mjs --file wordpress.xml [--dry-run]/* eslint-disable no-console */
import { readFileSync } from "fs";import { execSync } from "child_process";import { tmpdir } from "os";import { join } from "path";
const args = process.argv.slice(2);const fileArg = args.find((_, i) => args[i - 1] === "--file");const isDryRun = args.includes("--dry-run");const isRemote = args.includes("--remote"); // add this flag to push to production
if (!fileArg) { console.error( "Usage: node scripts/import-wp-comments.mjs --file wordpress.xml [--dry-run] [--remote]" ); process.exit(1);}
const DB_NAME = "blog-thuanbui-comments";
// ─── Parse XML manually (no external dependencies) ──────────────────function getTagValue(xml, tag) { // Handle both CDATA and regular text const cdataPattern = new RegExp( `<${tag}[^>]*><!\\[CDATA\\[([\\s\\S]*?)\\]\\]></${tag}>`, "i" ); const normalPattern = new RegExp(`<${tag}[^>]*>([^<]*)</${tag}>`, "i");
const cdataMatch = xml.match(cdataPattern); if (cdataMatch) return cdataMatch[1].trim();
const normalMatch = xml.match(normalPattern); if (normalMatch) return normalMatch[1].trim();
return "";}
// ─── Read and parse WordPress XML ───────────────────────────────────────────console.log(`Reading file: ${fileArg}`);const xmlContent = readFileSync(fileArg, "utf-8");
// Get all <item> (posts)const itemMatches = [];let searchPos = 0;while (true) { const itemStart = xmlContent.indexOf("<item>", searchPos); if (itemStart === -1) break; const itemEnd = xmlContent.indexOf("</item>", itemStart); if (itemEnd === -1) break; itemMatches.push(xmlContent.slice(itemStart, itemEnd + 7)); searchPos = itemEnd + 7;}
console.log(`Found ${itemMatches.length} posts in XML`);
// ─── Parse comments from each post ──────────────────────────────────────const allComments = [];const allEmails = new Set();
for (const item of itemMatches) { const postSlug = getTagValue(item, "wp:post_name"); const postStatus = getTagValue(item, "wp:status"); const postType = getTagValue(item, "wp:post_type");
// Only process published posts (post or page) if (postStatus !== "publish" || !["post", "page"].includes(postType)) continue; if (!postSlug) continue;
// Get all <wp:comment> in this item const commentMatches = []; let cPos = 0; const OPEN = "<wp:comment>"; const CLOSE = "</wp:comment>"; while (true) { const cStart = item.indexOf(OPEN, cPos); if (cStart === -1) break; const cEnd = item.indexOf(CLOSE, cStart); if (cEnd === -1) break; commentMatches.push(item.slice(cStart, cEnd + CLOSE.length)); cPos = cEnd + CLOSE.length; }
for (const commentXml of commentMatches) { const wpId = getTagValue(commentXml, "wp:comment_id"); const authorName = getTagValue(commentXml, "wp:comment_author"); const authorEmail = getTagValue(commentXml, "wp:comment_author_email"); const authorUrl = getTagValue(commentXml, "wp:comment_author_url"); const content = getTagValue(commentXml, "wp:comment_content"); const dateGmt = getTagValue(commentXml, "wp:comment_date_gmt"); const approved = getTagValue(commentXml, "wp:comment_approved"); const parentId = getTagValue(commentXml, "wp:comment_parent"); const commentType = getTagValue(commentXml, "wp:comment_type");
// Skip pingback, trackback and spam if (commentType === "pingback" || commentType === "trackback") continue; if (approved !== "1") continue; // only import approved comments if (!authorName || !content) continue;
const email = authorEmail || "noreply@migrated.local"; if (email && email !== "noreply@migrated.local") { allEmails.add(email); }
allComments.push({ wpId: parseInt(wpId) || null, wpParentId: parseInt(parentId) || 0, postSlug, authorName, authorEmail: email, authorUrl: authorUrl || null, content, createdAt: dateGmt ? new Date(dateGmt + "Z").toISOString() : new Date().toISOString(), status: 1, }); }}
console.log(`Total comments to import: ${allComments.length}`);
if (isDryRun) { console.log("\nDRY RUN - Not actually inserting into database"); console.log("First 5 comments:"); allComments.slice(0, 5).forEach((c, i) => { console.log( ` ${i + 1}. [${c.postSlug}] ${c.authorName}: "${c.content.slice(0, 60)}..."` ); }); console.log(`\nRun again without --dry-run to actually import.`); process.exit(0);}
// ─── Insert all comments, using wp_parent_id to resolve parent_id later ──const CHUNK_SIZE = 50;const chunks = [];for (let i = 0; i < allComments.length; i += CHUNK_SIZE) { chunks.push(allComments.slice(i, i + CHUNK_SIZE));}
console.log(`\nInsert ${chunks.length} batch...`);
let totalInserted = 0;
for (let i = 0; i < chunks.length; i++) { const chunk = chunks[i]; const { writeFileSync } = await import("fs"); const tmpFile = join(tmpdir(), `wp-import-batch-${i}.sql`);
// wp_parent_id is temp column to resolve parent_id const sql = `INSERT INTO comments (post_slug, author_name, author_email, author_url, content, created_at, status, wp_comment_id, wp_parent_id) VALUES\n ${chunk .map(c => { const escape = s => (s ? String(s).replace(/'/g, "''") : ""); return `('${escape(c.postSlug)}', '${escape(c.authorName)}', '${escape(c.authorEmail)}', ${c.authorUrl ? `'${escape(c.authorUrl)}'` : "NULL"}, '${escape(c.content)}', '${c.createdAt}', ${c.status}, ${c.wpId ?? "NULL"}, ${c.wpParentId})`; }) .join(",\n ")};`;
writeFileSync(tmpFile, sql, "utf-8");
const remoteFlag = isRemote ? "--remote --yes" : "--local"; const cmd = `npx wrangler d1 execute ${DB_NAME} ${remoteFlag} --file ${tmpFile}`;
try { execSync(cmd, { stdio: "inherit" }); totalInserted += chunk.length; console.log( `Batch ${i + 1}/${chunks.length} - ${totalInserted}/${allComments.length}` ); } catch (err) { console.error(`Error batch ${i + 1}:`, err.message); process.exit(1); }}
// ─── Resolve parent_id: child.wp_parent_id → parent.wp_comment_id ────────console.log(`\nResolve parent_id for nested comments...`);
const { writeFileSync: ws2 } = await import("fs");const resolveFile = join(tmpdir(), "wp-resolve-parents.sql");
// Join table with itself: child.wp_parent_id = parent.wp_comment_id → child.parent_id = parent.idconst resolveSql = `UPDATE comments AS childSET parent_id = ( SELECT id FROM comments AS parent WHERE parent.wp_comment_id = child.wp_parent_id AND parent.wp_comment_id IS NOT NULL LIMIT 1)WHERE child.wp_parent_id IS NOT NULL AND child.wp_parent_id != 0 AND child.parent_id IS NULL;`;
ws2(resolveFile, resolveSql, "utf-8");
try { const remoteFlag = isRemote ? "--remote --yes" : "--local"; execSync( `npx wrangler d1 execute ${DB_NAME} ${remoteFlag} --file ${resolveFile}`, { stdio: "inherit" } ); console.log(`Resolve parent_id completed`);} catch (err) { console.error("Error resolving parent_id:", err.message);}
console.log(`\nMigration complete! ${totalInserted} comments migrated to D1.`);
// ─── Insert trusted emails ──────────────────────────────────────────────────if (allEmails.size > 0) { console.log(`Inserting ${allEmails.size} trusted emails...`); const { writeFileSync: ws3 } = await import("fs"); const emailsFile = join(tmpdir(), "wp-trusted-emails.sql"); const now = new Date().toISOString(); const emailsSql = `INSERT INTO trusted_emails (email, added_at) VALUES\n ${Array.from( allEmails ) .map(email => { const escape = s => (s ? String(s).replace(/'/g, "''") : ""); return `('${escape(email)}', '${now}')`; }) .join(",\n ")};`;
ws3(emailsFile, emailsSql, "utf-8");
try { const remoteFlag = isRemote ? "--remote --yes" : "--local"; execSync( `npx wrangler d1 execute ${DB_NAME} ${remoteFlag} --file ${emailsFile}`, { stdio: "inherit" } ); console.log(`Trusted emails added.`); } catch (err) { console.error("Error inserting trusted emails:", err.message); }}3. Chạy migration
Script trên sẽ tự động làm 2 việc: nhập tất cả comment từ file XML vào D1, sau đó sẽ tự động cập nhật thông số parent_id thông qua qua wp_comment_id.
# 1. Dry run để thử nghiệm trướcnode scripts/import-wp-comments.mjs --file wordpress.xml --dry-runĐọc file: thunbi.xml📝 Tìm thấy 2972 bài viết trong XML💬 Tổng số comment sẽ import: 1850
🔍 DRY RUN — Không thực sự insert vào database5 comment đầu tiên: 1. [about] Long: "Xin chào, mình cần giúp đỡ để setup server mình thông qua vp..." 2. [about] kaka: "rất nhiều bài viết hay, hãy duy trì nó nhé, chúc bạn mạnh kh..." 3. [about] Quý: "Bác lên bài hướng dẫn cài mattermost cho xpenology đi bác. E..." 4. [support] tao nè: "hâm mộ anh Thuận Bùi quá :D..." 5. [support] Thuận Bùi: "Tao nè là thằng nào? Phải thằng VA làm Engine Theme không? H..."
Chạy lại không có --dry-run để import thật vào databasenode scripts/import-wp-comments.mjs --file wordpress.xmlXác nhận lại trên local — kiểm tra parent_id đã được cập nhật chính xác chưa
npx wrangler d1 execute blog-thuanbui-comments --local \ --command "SELECT id, author_name, parent_id, wp_comment_id FROM comments WHERE parent_id IS NOT NULL LIMIT 10"X. Áp dụng lên production
Sau khi thử nghiệm trên local thấy ổn, mình sẽ áp dụng lên Cloudflare D1
1. Tạo table trên D1 production
npm run db:migrate:prodNhập comment từ wrodpress vào D1
node scripts/import-wp-comments.mjs --file wordpress.xml --remote2. Xác nhận
Dùng lệnh sau để kiẻm tra số lượng comment trên blog
# Kiểm tra productionnpx wrangler d1 execute blog-thuanbui-comments --remote \ --command "SELECT COUNT(*) FROM comments"Kết quả trả về tương tự nhu sau nghĩa là comment đã dược nhập vào D1 thành công
⛅️ wrangler 4.78.0 (update available 4.80.0)─────────────────────────────────────────────Resource location: remote
🌀 Executing on remote database blog-thuanbui-comments (7ffe7eee-9d2b-4958-befd-2404d710d903):🌀 To execute on your local development database, remove the --remote flag from your wrangler command.🚣 Executed 1 command in 1.11ms┌──────────┐│ COUNT(*) │├──────────┤│ 1851 │└──────────┘Vậy là xong. Blog Thuanbui.me giờ đã có hệ thống comment hoạt động ngon lành, mọi người có thể kéo xuống dưới cùng bài viết này để xem và gửi bình luận để test thử nếu muốn.
Tổng kết
Kiểm tra lại các bước thao tác xem đã dầy đủ chưa
- Tạo D1 database
blog-thuanbui-comments - Cập nhật
wrangler.jsoncvới D1 binding +nodejs_compatflag - Cài packages cần thiết
- Generate types, migration
- Tạo schema, API, UI component
- Test local, migrate comment cũ
- Deploy production
Sau khi hoàn thành, mình đã có được hệ thống comment hoàn chỉnh cho blog Thuanbui.me
- Tất cả comment cũ từ WordPress được giữ lại thành công
- Không quảng cáo, không phụ thuộc bên thứ ba
- Mọi người có thể bình luận dễ dàng, không cần phải đăng ký tài khoản ở bất kỳ đâu.
Quan trọng hơn, toàn bộ hệ thống chạy hoàn toàn trên Cloudflare — đồng bộ với các dịch vụ khác mình đang dùng như Worker, R2, Zero Trust, DNS.
Trong các phần tiếp theo, mình sẽ tiếp tục hoàn thiện hệ thống:
- Phần 2: Tối ưu hiệu năng cho comment với Cloudflare KV và D1 Index
- Phần 3: Xây dựng dashboard để kiểm duyệt comment
Hy vọng bài viết này sẽ giúp bạn tự tin xây dựng hệ thống bình luận riêng cho blog của mình, hoặc đơn giản là tham khảo để tối ưu stack hiện tại. Nếu có thắc mắc gì, cứ để lại bình luận bên dưới nhé!