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

Đây là bài viết Phần 6 nằm trong series: WordPress to Astro Migration

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-lich thay 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-10
categories:
  - du-lich
tags:
  - nhat-ban
---
Code language: YAML (yaml)

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>
Code language: HTML, XML (xml)

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

git clone -b feat/add_custom_taxonomy https://github.com/10h30/wordpress-export-to-markdown.gitCode language: PHP (php)

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

cd wordpress-export-to-markdown
npm installCode language: JavaScript (javascript)

Sau đó 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:pubDate,categories,tags,coverImage:image,draft,slug \
  --save-images=all \
  --date-folders=none
  --include-custom-taxonomies=true
  --save-taxonomy-data=true
Code language: Nginx (nginx)

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
---
Code language: YAML (yaml)

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.jsonCode language: CSS (css)

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."
        }
]Code language: JSON / JSON with Comments (json)

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
Code language: Nginx (nginx)

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(),
    pubDate: 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 };
Code language: YAML (yaml)

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;
}
Code language: TypeScript (typescript)

3. Cập nhật Component để hiển thị tên đầy đủ

Mình cần cập nhật các file src/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="inline-block relative">
          <a
            href={`/category/${getTaxonomyPath("category", category)}`}
            class="inline-block bg-primary/10 hover:bg-primary/20 text-primary hover:text-primary px-3 py-1 rounded-full border border-primary/20 transition-colors duration-200"
          >
            {categoryMap.get(category)?.name ?? category}
          </a>
        </li>
      ))}
    </ul>
  )
}
Code language: TypeScript (typescript)

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="inline-block relative">
          <a
            href={`/destination/${getTaxonomyPath("destination", destination)}`}
            class="inline-flex items-center gap-1 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-700 dark:text-emerald-400 hover:text-emerald-800 dark:hover:text-emerald-300 px-3 py-1 rounded-full border border-emerald-500/20 transition-colors duration-200"
          >
            <svg
              xmlns="http://www.w3.org/2000/svg"
              class="w-3 h-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>
  )
}
Code language: TypeScript (typescript)

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,
  pubDate,
  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 font-bold leading-tight 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="flex flex-wrap justify-center gap-2 mt-4">
              <Destinations destinations={destination} />
            </div>
          )
        }
// ...
</BaseLayout>
Code language: TypeScript (typescript)

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="mt-4 relative z-20">
          <Destinations destinations={post.data.destination} />
        </div>
      )
    }
Code language: YAML (yaml)

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.pubDate);

  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.pubDate.valueOf() - a.data.pubDate.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 lg:px-8 md:py-12">
    <div class="relative mx-auto max-w-5xl">
      <Headline
        tagline="Category"
        title={cat.name}
        subtitle={cat.description ?? `Hin 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>
Code language: JavaScript (javascript)

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!

Theo dõi
Thông báo của
guest
0 Comments
Cũ nhất
Mới nhất Được bỏ phiếu nhiều nhất
Phản hồi nội tuyến
Xem tất cả bình luận