Sau khi hoàn thành [Phần 4], blog Astro của mình đã chạy ngon trên Cloudflare Workers. Nhưng như đã đề cập trong bài viết, vẫn còn một số việc cần xử lý trước khi chính thức “khai tử” WordPress:
-
Trang category/tag đang hiển thị slug thay vì tên đầy đủ — ví dụ
du-lichthay vì “Du Lịch” -
Custom taxonomy (như
destination) chưa được xuất ra khi chạy chuyển đổi từ xml qua markdown -
Category chưa có cấu trúc phân cấp như bên WordPress
Bài viết [Phần 6] sẽ hướng dẫn cách mình khắc phục 3 vấn đề trên, sẵn sàng chính thức tắt WordPress và chuyển nhà cho blog balodeplao.com qua Astro.
I. Chi tiết về các vấn đề hiện tại
Khi chạy công cụ wordpress-export-to-markdown để chuyển nội dung WordPress sang file Markdown, frontmatter của mỗi bài viết sẽ trông như thế này:
---title: "3 ngày ở Tokyo"date: 2024-11-10categories: - du-lichtags: - nhat-ban---Nhìn qua thì có thể tạm ổn, nhưng trên thực tế phần frontmatter đang thiếu khá nhiều thứ:
-
Custom taxonomy bị bỏ qua hoàn toàn. Blog balodeplao.com của mình có taxonomy
destinationđể gắn địa điểm cho bài viết — ví dụ Japan, Taiwan, Việt Nam. Sau khi export, toàn bộ thông tin này biến mất. -
Category và tag chỉ có slug, không có tên đầy đủ.
du-lichlà slug, còn tên thật là “Du Lịch”. Khi Astro render trang category, nó dùng slug để hiển thị tiêu đề → vừa không thân thiện, vừa ảnh hưởng SEO. -
Không có metadata cho taxonomy. Bên WordPress, mỗi category/tag có đầy đủ thông tin: tên hiển thị, mô tả, category cha (parent). Toàn bộ thông tin đó nằm trong file XML xuất ra nhưng công cụ bỏ qua các thông tin khi xử lý chuyển đổi qua markdown.
Trên thực tế, thông tin về custom taxonomy và metadata cho taxonomy đều có sẵn trong file xml khi xuất nội dung từ WordPress.
<!-- Metadata của category, có thông tin parent để xây phân cấp --><wp:category> <wp:category_nicename>chau-a</wp:category_nicename> <wp:cat_name><![CDATA[Châu Á]]></wp:cat_name> <wp:category_parent>du-lich</wp:category_parent> <wp:category_description><![CDATA[Các bài viết du lịch Châu Á]]></wp:category_description></wp:category>
<!-- Metadata của tag --><wp:tag> <wp:tag_slug>nhat-ban</wp:tag_slug> <wp:tag_name><![CDATA[Nhật Bản]]></wp:tag_name></wp:tag>
<!-- Metadata của custom taxonomy term --><wp:term> <wp:term_taxonomy>destination</wp:term_taxonomy> <wp:term_slug>japan</wp:term_slug> <wp:term_name><![CDATA[Japan]]></wp:term_name> <wp:term_parent></wp:term_parent></wp:term>Vấn đề nằm ở chỗ công cụ wordpress-export-to-markdown không hỗ trợ xử lý custom taxonomy và metadata cho custom taxonomy.
II. Giải pháp: fork và bổ sung tính năng
Để giải quyết những vấn đề kể trên, mình quyết định fork công cụ wordpress-export-to-markdown và bổ sung thêm 2 tính năng cần thiết:
-
Hỗ trợ xử lý custom taxonomy cho từng bài viết: ghi vào frontmatter tương ứng.
-
Xử lý thông ting metadata cho taxonomy: đọc các block
<wp:category>,<wp:tag>,<wp:term>và xuất ra filetaxonomies.json.
Mọi người có thể tham khảo bản fork của mình ở đây:
https://github.com/10h30/wordpress-export-to-markdown/tree/feat/add_custom_taxonomy
1. Xử lý lại file xml
Để xử lý lại file markdown, cần phải clone repo này về máy
git clone -b feat/add_custom_taxonomy https://github.com/10h30/wordpress-export-to-markdown.gitTruy cập vào thư mục và cài đặt package
cd wordpress-export-to-markdownnpm installSau đó chạy lệnh sau để chuyển đổi file xml qua markdown.
node app \ --prefix-date=true \ --post-folders=false \ --frontmatter-fields=title,author,date:pubDatetime,categories,tags,coverImage:image,draft,slug \ --save-images=all \ --date-folders=none \ --include-custom-taxonomies=true \ --save-taxonomy-data=true2. Kết quả frontmatter sau khi sửa
Phần frontmatter của các file md giờ đã có thêm thông tin về Custom taxonomy destination
---title: "3 ngày ở Tokyo"date: 2024-11-10categories: - du-lichtags: - nhat-bandestination: - japan - tokyo---3. Kết quả taxonomies.json
Trong thư mục output giờ sẽ có thêm thư mục taxonomies, gồm các file json của từng custom taxonomy.
taxonomies├── advanced_ads_groups.json├── category.json├── cp_campaign.json├── cp_connections.json├── destination.json├── nav_menu.json└── post_tag.jsonVà đây là nội dung của file category.json
[ { "slug": "an-uong", "name": "Ẩm Thực", "description": "Cẩm nang trải nghiệm ẩm thực, giới thiệu các quán ăn ngon của Ba Lô Dép Lào" }, { "slug": "cong-viec", "name": "Công Việc" }, { "slug": "du-lich", "name": "Du Lịch", "description": "Chia sẻ trải nghiệm, kinh nghiệm du lịch khám phá thế giới của Ba Lô & Dép Lào" }, { "slug": "gia-dinh", "name": "Gia Đình" }, { "slug": "kham-pha-cuoi-tuan", "name": "Khám Phá Cuối Tuần" }, { "slug": "khuyen-mai", "name": "Khuyến Mãi", "description": "Tổng hợp các thông tin khuyến mãi khách sạn, vé máy bay, ăn uống,..." }, { "slug": "lam-dep", "name": "Làm Đẹp", "description": "Chia sẻ kinh nghiệm làm đẹp từ các nguyên liệu tự nhiên :)" }, { "slug": "me-con", "name": "Mẹ và Con", "description": "Các bài viết chia sẻ kinh nghiệm làm mẹ và nuôi con" }, { "slug": "meo-hay", "name": "Mẹo Hay" }, { "slug": "qua-tang", "name": "Quà Tặng" }, { "slug": "tam-su", "name": "Tâm Sự" }, { "slug": "thu-cung", "name": "Thú Cưng", "description": "Các bài viết về cách huấn luyện, nuôi dưỡng và vui chơi cùng thú cưng" }, { "slug": "tin-tuc", "name": "Tin Tức" }, { "slug": "yoga", "name": "Yoga", "description": "Chia sẻ kinh nghiệm tập Yoga." }]Mình sẽ xóa các file không cần thiết, chỉ giữ lại category, post_tag và destination là những custom taxonomy sử dụng trong bài viết. Các file này sẽ là nguồn dữ liệu cho taxonomy trên Astro blog — từ tên hiển thị, mô tả, đến cấu trúc phân cấp.
III. Tích hợp vào Astro
Việc cần làm tiếp theo là tích hợp các thông tin mới vào Astro
-
Tạo util
taxonomy.tsđể xử lý thông tin metadata cho taxonomy, lấy từ các file json. -
Cập nhật component
Category.astro,Tag.astrođể hiển thị tên đầy đủ cho taxonomy thay vì slug -
Tạo component
Destination.astrocho custom taxonomy `destination, chèn vào[...slug].astrovàPostItem.astro -
Cập nhật lại URL cho category / destination hỗ trợ phân cấp, ví dụ:
/destination/nhat-ban/tokyo
1. Copy các file json vào Astro
Copy các thư mục taxonomies vào thư mục src/content/ trong project Astro. Cấu trúc thư mục content giờ sẽ như sau
content├── blog└── taxonomies2. Khai báo custom taxonomy
Cập nhật file content.config.ts, bổ sung khai báo cho custom taxonomy mới: destination
import { defineCollection, z } from "astro:content";import { glob } from "astro/loaders";
const blog = defineCollection({ loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/blog" }), schema: z.object({ title: z.string(), description: z.string().optional(), pubDatetime: z.coerce.date(), author: z.string(), image: z.string().optional(), tags: z.array(z.string()).optional(), categories: z.array(z.string()).optional(), destination: z.array(z.string()).optional(), }),});
export const collections = { blog };3. Tạo util xử lý taxonomy
Tạo file src/util/taxonomy.ts:
import categoryData from "@/content/taxonomies/category.json";import destinationData from "@/content/taxonomies/destination.json";import tagData from "@/content/taxonomies/tag.json";
export type TaxonomyItem = { slug: string; name: string; parent?: string; description?: string;};
export type TaxonomyMap = Map<string, TaxonomyItem>;
export type TaxonomyType = "category" | "destination" | "tag";
function isTaxonomyItem(value: unknown): value is TaxonomyItem { if (typeof value !== "object" || value === null) return false; const v = value as Record<string, unknown>; return ( typeof v.slug === "string" && typeof v.name === "string" && (v.parent === undefined || typeof v.parent === "string") && (v.description === undefined || typeof v.description === "string") );}
function buildMap(data: TaxonomyItem[]): TaxonomyMap { if (!Array.isArray(data)) { throw new Error(`[taxonomy] expected an array but received ${typeof data}`); } return new Map( data.map((item, i) => { if (!isTaxonomyItem(item)) { throw new Error( `[taxonomy] invalid item at index ${i}: ${JSON.stringify(item)}` ); } return [item.slug, item]; }) );}
const taxonomies: Record<TaxonomyType, TaxonomyMap> = { category: buildMap(categoryData as TaxonomyItem[]), destination: buildMap(destinationData as TaxonomyItem[]), tag: buildMap(tagData as TaxonomyItem[]),};
export function getTaxonomyMap(type: TaxonomyType): TaxonomyMap { if (!(type in taxonomies)) { throw new Error( `[taxonomy] unknown taxonomy type "${type}". Expected one of: ${Object.keys(taxonomies).join(", ")}` ); } return taxonomies[type];}
function buildHierarchicalPath( slug: string, map: TaxonomyMap, visited = new Set<string>()): string { if (visited.has(slug)) return slug; // cycle protection visited.add(slug); const item = map.get(slug); if (!item?.parent) return slug; return `${buildHierarchicalPath(item.parent, map, visited)}/${slug}`;}
export function getTaxonomyPath(type: TaxonomyType, slug: string): string { return buildHierarchicalPath(slug, getTaxonomyMap(type));}
export function getTaxonomyItem( type: TaxonomyType, slug: string): TaxonomyItem | undefined { return getTaxonomyMap(type).get(slug);}
export function getDescendantSlugs( taxonomyType: string, slug: string): string[] { const map = getTaxonomyMap(taxonomyType); const result: string[] = [];
for (const [key, item] of map.entries()) { if (key === slug) continue; // Đi ngược từ node này lên root, kiểm tra xem có qua slug không let current: TaxonomyItem | undefined = item; while (current?.parent) { if (current.parent === slug) { result.push(key); break; } current = map.get(current.parent); } }
return result;}3. Cập nhật Component để hiển thị tên đầy đủ
Mình cần cập nhật các file s`rc/components/ui/`Categories.astro và src/components/ui/Tags.astro để cập nhật đường dẫn URL (sử dụng hàm getTaxonomyPath) và tên hiển thị (sử dụng hàm getTaxonomyMap)
---import { getTaxonomyMap, getTaxonomyPath } from "@/utils/taxonomy";
export interface Props { categories: string[]; class?: string;}
const { categories, class: className = "text-sm" } = Astro.props;const categoryMap = getTaxonomyMap("category");---
{ categories && Array.isArray(categories) && ( <ul class:list={["flex flex-wrap gap-2", className]}> {categories.map(category => ( <li class="relative inline-block"> <a href={`/category/${getTaxonomyPath("category", category)}`} class="bg-primary/10 hover:bg-primary/20 text-primary hover:text-primary border-primary/20 inline-block rounded-full border px-3 py-1 transition-colors duration-200" > {categoryMap.get(category)?.name ?? category} </a> </li> ))} </ul> )}Kết quả: phần tên của Category, thay vì hiện du-lich, sẽ hiện ra tên đầy đủ “Du Lịch” và trỏ đúng đến /category/du-lich.
4. Tạo component Destination.astro
Tạo file src/components/uiDestinations.astro — về cơ bản giống hệt file Categories.astro, chỉ thay taxonomy type từ "category" sang "destination" và prefix URL thành /destination/:
---import { getTaxonomyMap, getTaxonomyPath } from "@/utils/taxonomy";
export interface Props { destinations: string[]; class?: string;}
const { destinations, class: className = "text-sm" } = Astro.props;const destinationMap = getTaxonomyMap("destination");---
{ destinations && Array.isArray(destinations) && ( <ul class:list={["flex flex-wrap gap-2", className]}> {destinations.map(destination => ( <li class="relative inline-block"> <a href={`/destination/${getTaxonomyPath("destination", destination)}`} class="inline-flex items-center gap-1 rounded-full border border-emerald-500/20 bg-emerald-500/10 px-3 py-1 text-emerald-700 transition-colors duration-200 hover:bg-emerald-500/20 hover:text-emerald-800 dark:text-emerald-400 dark:hover:text-emerald-300" > <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" > <path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" /> <circle cx="12" cy="10" r="3" /> </svg> {destinationMap.get(destination)?.name ?? destination} </a> </li> ))} </ul> )}Sau đó chèn component này vào hai nơi:
Trong [...slug].astro (trang bài viết đầy đủ):
---import Destinations from "@/components/ui/Destinations.astro";
// ...const { title, description, pubDatetime, author, image, categories, tags, destination, // thêm destination vào danh sách} = post.data;---
<BaseLayout metadata={metadata}> // ... <h1 class="font-heading mt-2 text-3xl leading-tight font-bold tracking-tighter text-foreground sm:text-4xl md:text-5xl" > {title} </h1> // Hiển thị dưới tên bài viết { destination && destination.length > 0 && ( <div class="mt-4 flex flex-wrap justify-center gap-2"> <Destinations destinations={destination} /> </div> ) } // ...</BaseLayout>Trong PostItem.astro (card bài viết trong listing)
---import Destinations from "@/components/ui/Destinations.astro";// ...---
{/* Trong phần meta của card */}{ post.data.destination && post.data.destination.length > 0 && ( <div class="relative z-20 mt-4"> <Destinations destinations={post.data.destination} /> </div> )}5. Trang category với URL phân cấp
Đây là phần rắc rối nhất, vì mình vừa muốn URL hỗ trợ phân cấp (/desitnation/nhat-ban/tokyo), vừa muốn sử dụng pagination chia bài viết theo trang (`/desitnation/nhat-ban/tokyo`/page/2).
Nếu dùng route [...slug].astro thông thường sẽ không xử lý được pagination.. Nếu dùng [page].astro thì không tùy chỉnh được cấu trúc URL.
Giải pháp là dùng [...rest].astro giữ toàn bộ phần URL sau taxonomy prefix (/destination hoặc /category/) - bao gồm cả path phân cấp lẫn số trang, rồi tự build danh sách path trong getStaticPaths thông qua hàm buildPaginatedPaths có sẵn.
Tạo file src/pages/category/[...rest].astro với nội dung sau
---import BaseLayout from "@/layouts/BaseLayout.astro";import { getCollection } from "astro:content";import type { CollectionEntry } from "astro:content";import Headline from "@/components/ui/Headline.astro";import PostItem from "@/components/blog/PostItem.astro";import Pagination from "@/components/ui/Pagination.astro";import { getTaxonomyMap, getTaxonomyPath, getDescendantSlugs, type TaxonomyItem,} from "@/utils/taxonomy";import { buildPaginatedPaths } from "@/utils/paginate";
interface Props { cat: TaxonomyItem; posts: CollectionEntry<"blog">[]; currentPage: number; lastPage: number; total: number; prevUrl?: string; nextUrl?: string;}
export async function getStaticPaths() { const allPosts = await getCollection("blog"); const posts = allPosts.filter(post => post.data && post.data.pubDatetime);
const catSlugs = new Set<string>(); posts.forEach(post => post.data.categories?.forEach(c => catSlugs.add(c)));
return Array.from(catSlugs).flatMap(slug => { const descendants = getDescendantSlugs("category", slug); const allSlugs = [slug, ...descendants];
const catPosts = posts .filter(post => post.data.categories?.some(d => allSlugs.includes(d))) .sort( (a, b) => b.data.pubDatetime.valueOf() - a.data.pubDatetime.valueOf() );
const fullPath = getTaxonomyPath("category", slug); const cat = getTaxonomyMap("category").get(slug) ?? ({ slug, name: slug } as TaxonomyItem);
return buildPaginatedPaths(catPosts, `/category/${fullPath}`).map( ({ pageParam, page }) => ({ params: { rest: pageParam ? `${fullPath}/${pageParam}` : fullPath }, props: { cat, posts: page.data, currentPage: page.currentPage, lastPage: page.lastPage, total: page.total, prevUrl: page.url.prev, nextUrl: page.url.next, }, }) ); });}
const { cat, posts, currentPage, lastPage, total, prevUrl, nextUrl } = Astro.props as Props;
const page = { currentPage, lastPage, url: { prev: prevUrl, next: nextUrl },};
const metadata = { title: cat.name, description: cat.description ?? `Các bài viết trong danh mục ${cat.name}.`,};---
<BaseLayout metadata={metadata}> <section class="relative px-4 py-8 sm:px-6 md:py-12 lg:px-8"> <div class="relative mx-auto max-w-5xl"> <Headline tagline="Category" title={cat.name} subtitle={cat.description ?? `Hiển thị ${total} bài viết.`} classes={{ container: "mb-12 text-center", title: "font-heading mb-4 text-4xl font-bold tracking-tight text-foreground md:text-6xl capitalized", subtitle: "mx-auto max-w-3xl text-xl text-muted-foreground", }} />
<div class="mb-8 text-center"> <a href="/blog" class="text-primary hover:underline">← Quay lại blog</a> </div>
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-3"> {posts.map(post => <PostItem post={post} />)} </div> <Pagination page={page} /> </div> </section></BaseLayout>Với cách này, Astro sẽ tự sinh ra đầy đủ các URL tại build time:
-
/category/du-lich -
/category/du-lich/page/2,/category/du-lich//page/3… -
/category/du-lich/chau-a -
/category/du-lich/chau-a/nhat-ban/page/2

Áp dụng tương tự cho destination, đổi taxonomy type từ category sang destination
Tổng kết
Đến đây, toàn bộ những việc cần làm mình đã liệt kê ở [Phần 4] đã được giải quyết xong - custom taxonomy đã có trong frontmatter, tên taxonomy hiển thị đầy đủ, URL phân cấp hoạt động đúng, pagination chạy ngon.
Blog balodeplao.com đã được chính thức được chuyển qua sử dụng Astro. Bye bye WordPress!
Ngay sau đó, mình cũng đã chuyển blog supersilk.vn qua Astro dựa theo kinh nghiệm đúc kết từ lần đầu tiên. Lần này chỉ mất khoảng 30’ là xong việc dọn nhà so với lần đầu tiên tốn gần cả tuần mày mò.
Series hướng dẫn WordPress to Astro Migration đến đây xin hết. Các bạn có thể tham khảo toàn bộ mã nguồn mình đã chia sẻ trong 6 phần vừa qua ở đây: https://github.com/10h30/blog-balodeplao
Nhìn lại cả quá trình, mình cực kỳ hài lòng với kết quả đạt được: blog tải siêu nhanh, dễ dàng tùy biến tính năng qua VS Code, không phải bận tâm quản lý server hay database, toàn bộ nội dung và hình ảnh được lưu trữ trên Cloudflare miễn phí.
Quan trọng hơn, mình học được rất nhiều kiến thức mới trong quá trình này: Jamstack, CI/CD, Cloudflare Workers, Cloudflare Image Transformation, Astro framework - những công nghệ web hiện đại mà trước giờ chưa có dịp đụng tới. Các kiến thức này chắc chắn không chỉ hữu ích cho việc xây dựng, mà còn mở ra khá nhiều hướng để áp dụng vào công việc về sau.
Nếu bạn đang cân nhắc chuyển blog từ WordPress sang Astro, hy vọng series này giúp ích được phần nào.
Hẹn gặp lại ở các bài viết tiếp theo!