Skip to content
Thuan Bui's Blog
Go back

[WordPress → Astro] Phần 6 – Xuất custom taxonomy và hỗ trợ phân cấp

Updated:

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:

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-10
categories:
- du-lich
tags:
- 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ứ:

  1. 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.

  2. Category và tag chỉ có slug, không có tên đầy đủ. du-lich là 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.

  3. 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:

  1. Hỗ trợ xử lý custom taxonomy cho từng bài viết: ghi vào frontmatter tương ứng.

  2. Xử lý thông ting metadata cho taxonomy: đọc các block <wp:category>, <wp:tag>, <wp:term> và xuất ra file taxonomies.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

Terminal window
git clone -b feat/add_custom_taxonomy https://github.com/10h30/wordpress-export-to-markdown.git

Truy cập vào thư mục và cài đặt package

Terminal window
cd wordpress-export-to-markdown
npm install

Sau đó chạy lệnh sau để chuyển đổi file xml qua markdown.

Terminal window
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=true

2. 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-10
categories:
- du-lich
tags:
- nhat-ban
destination:
- 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.json

Và đâ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ô &amp; 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

  1. Tạo util taxonomy.ts để xử lý thông tin metadata cho taxonomy, lấy từ các file json.

  2. Cập nhật component Category.astro, Tag.astro để hiển thị tên đầy đủ cho taxonomy thay vì slug

  3. Tạo component Destination.astro cho custom taxonomy `destination, chèn vào [...slug].astroPostItem.astro

  4. 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
└── taxonomies

2. 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.astrosrc/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:

BLDL Blog
BLDL Blog

Á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!


Share this post on:

Previous Post
[Wordpress → Astro] Phần 1 - Chuyển bài viết từ Database qua file Markdown
Next Post
[WordPress → Astro] Phần 5 - Tối ưu hình ảnh cho blog