From ccfade04fd46833a2b9eb1911699a0d751ec0502 Mon Sep 17 00:00:00 2001 From: Thuan Bui <9248622+10h30@users.noreply.github.com> Date: Thu, 19 Mar 2026 20:32:59 +0900 Subject: [PATCH] feat: implement taxonomy data for categories, destinations, and tags with updated routing (#2) --- src/components/ui/Categories.astro | 7 +- src/components/ui/Destinations.astro | 7 +- src/components/ui/Pagination.astro | 10 +- src/components/ui/Tags.astro | 5 +- src/pages/[...slug].astro | 8 +- src/pages/blog/[...page].astro | 24 ++++- src/pages/category/[...rest].astro | 101 ++++++++++++++++++ src/pages/category/[category]/[...page].astro | 64 ----------- src/pages/destination/[...rest].astro | 101 ++++++++++++++++++ .../destination/[destination]/[...page].astro | 64 ----------- src/pages/tag/[tag]/[...page].astro | 40 +++++-- src/utils/paginate.ts | 53 +++++++++ src/utils/taxonomy.ts | 79 ++++++++++++++ 13 files changed, 410 insertions(+), 153 deletions(-) create mode 100644 src/pages/category/[...rest].astro delete mode 100644 src/pages/category/[category]/[...page].astro create mode 100644 src/pages/destination/[...rest].astro delete mode 100644 src/pages/destination/[destination]/[...page].astro create mode 100644 src/utils/paginate.ts create mode 100644 src/utils/taxonomy.ts diff --git a/src/components/ui/Categories.astro b/src/components/ui/Categories.astro index 8fc8979..996459d 100644 --- a/src/components/ui/Categories.astro +++ b/src/components/ui/Categories.astro @@ -1,10 +1,13 @@ --- +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"); --- { @@ -13,10 +16,10 @@ const { categories, class: className = "text-sm" } = Astro.props; {categories.map((category) => (
  • - {category} + {categoryMap.get(category)?.name ?? category}
  • ))} diff --git a/src/components/ui/Destinations.astro b/src/components/ui/Destinations.astro index 4ab9c09..d2b19d3 100644 --- a/src/components/ui/Destinations.astro +++ b/src/components/ui/Destinations.astro @@ -1,10 +1,13 @@ --- +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"); --- { @@ -13,7 +16,7 @@ const { destinations, class: className = "text-sm" } = Astro.props; {destinations.map((destination) => (
  • - {destination} + {destinationMap.get(destination)?.name ?? destination}
  • ))} diff --git a/src/components/ui/Pagination.astro b/src/components/ui/Pagination.astro index 5ac2532..bd9322e 100644 --- a/src/components/ui/Pagination.astro +++ b/src/components/ui/Pagination.astro @@ -1,10 +1,14 @@ --- -import type { Page } from "astro"; -import type { CollectionEntry } from "astro:content"; import { Icon } from "astro-icon/components"; +export interface SimplePage { + currentPage: number; + lastPage: number; + url: { prev?: string; next?: string }; +} + export interface Props { - page: Page>; + page: SimplePage; class?: string; } diff --git a/src/components/ui/Tags.astro b/src/components/ui/Tags.astro index 6432375..8e0bd99 100644 --- a/src/components/ui/Tags.astro +++ b/src/components/ui/Tags.astro @@ -1,10 +1,13 @@ --- +import { getTaxonomyMap } from "@/utils/taxonomy"; + export interface Props { tags: string[]; class?: string; } const { tags, class: className = "text-sm" } = Astro.props; +const tagMap = getTaxonomyMap("tag"); --- { @@ -16,7 +19,7 @@ const { tags, class: className = "text-sm" } = Astro.props; href={`/tag/${tag}`} class="inline-block bg-muted/50 hover:bg-muted text-muted-foreground hover:text-foreground px-3 py-1 rounded-full border border-border transition-colors duration-200" > - #{tag} + #{tagMap.get(tag)?.name ?? tag} ))} diff --git a/src/pages/[...slug].astro b/src/pages/[...slug].astro index 4a38141..0957672 100644 --- a/src/pages/[...slug].astro +++ b/src/pages/[...slug].astro @@ -7,7 +7,11 @@ import Destinations from "@/components/ui/Destinations.astro"; import { toR2Url } from "@/utils/r2"; import Picture from "@/components/ui/Picture.astro"; -import { getCollection, render } from "astro:content"; +import { getCollection, render, type CollectionEntry } from "astro:content"; + +interface Props { + post: CollectionEntry<"blog">; +} export async function getStaticPaths() { const blog = await getCollection("blog"); @@ -17,7 +21,7 @@ export async function getStaticPaths() { })); } -const { post } = Astro.props; +const { post } = Astro.props as Props; const { Content, remarkPluginFrontmatter } = await render(post); const { title, diff --git a/src/pages/blog/[...page].astro b/src/pages/blog/[...page].astro index 621b3d7..851c7d6 100644 --- a/src/pages/blog/[...page].astro +++ b/src/pages/blog/[...page].astro @@ -1,21 +1,37 @@ --- 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 Pagination from "@/components/ui/Pagination.astro"; import PostItem from "@/components/blog/PostItem.astro"; -import type { GetStaticPathsOptions } from "astro"; +import { buildPaginatedPaths } from "@/utils/paginate"; -export async function getStaticPaths({ paginate }: GetStaticPathsOptions) { +interface Props { + page: { + data: CollectionEntry<"blog">[]; + total: number; + currentPage: number; + lastPage: number; + url: { prev?: string; next?: string }; + }; +} + +export async function getStaticPaths() { const blogEntries = await getCollection("blog"); const sortedPosts = blogEntries .filter((post) => post.data && post.data.pubDate) .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()); - return paginate(sortedPosts, { pageSize: 6 }); + return buildPaginatedPaths(sortedPosts, "/blog").map( + ({ pageParam, page }) => ({ + params: { page: pageParam }, + props: { page }, + }), + ); } -const { page } = Astro.props; +const { page } = Astro.props as Props; const blog = page.data; const metadata = { diff --git a/src/pages/category/[...rest].astro b/src/pages/category/[...rest].astro new file mode 100644 index 0000000..dbcd661 --- /dev/null +++ b/src/pages/category/[...rest].astro @@ -0,0 +1,101 @@ +--- +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, + 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(); + posts.forEach((post) => + post.data.categories?.forEach((c) => catSlugs.add(c)), + ); + + return Array.from(catSlugs).flatMap((slug) => { + const catPosts = posts + .filter((post) => post.data.categories?.includes(slug)) + .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}.`, +}; +--- + + +
    +
    + + + + +
    + {posts.map((post) => )} +
    + +
    +
    +
    diff --git a/src/pages/category/[category]/[...page].astro b/src/pages/category/[category]/[...page].astro deleted file mode 100644 index ed319ac..0000000 --- a/src/pages/category/[category]/[...page].astro +++ /dev/null @@ -1,64 +0,0 @@ ---- -import BaseLayout from "@/layouts/BaseLayout.astro"; -import { getCollection } 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 type { GetStaticPathsOptions } from "astro"; - -export async function getStaticPaths({ paginate }: GetStaticPathsOptions) { - const allPosts = await getCollection("blog"); - const posts = allPosts.filter((post) => post.data && post.data.pubDate); - - const categories = new Set(); - posts.forEach((post) => { - post.data.categories?.forEach((cat) => categories.add(cat)); - }); - - return Array.from(categories).flatMap((category) => { - const categoryPosts = posts - .filter((post) => post.data.categories?.includes(category)) - .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()); - - return paginate(categoryPosts, { - params: { category }, - pageSize: 6, - }); - }); -} - -const { page } = Astro.props; -const { category } = Astro.params; - -const metadata = { - title: `Category: ${category}`, - description: `Articles in the ${category} category.`, -}; ---- - - -
    -
    - - - - -
    - {page.data.map((post) => )} -
    - -
    -
    -
    diff --git a/src/pages/destination/[...rest].astro b/src/pages/destination/[...rest].astro new file mode 100644 index 0000000..bc6537a --- /dev/null +++ b/src/pages/destination/[...rest].astro @@ -0,0 +1,101 @@ +--- +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, + type TaxonomyItem, +} from "@/utils/taxonomy"; +import { buildPaginatedPaths } from "@/utils/paginate"; + +interface Props { + dest: 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 destSlugs = new Set(); + posts.forEach((post) => + post.data.destination?.forEach((d) => destSlugs.add(d)), + ); + + return Array.from(destSlugs).flatMap((slug) => { + const destPosts = posts + .filter((post) => post.data.destination?.includes(slug)) + .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()); + + const fullPath = getTaxonomyPath("destination", slug); + const dest = + getTaxonomyMap("destination").get(slug) ?? + ({ slug, name: slug } as TaxonomyItem); + + return buildPaginatedPaths(destPosts, `/destination/${fullPath}`).map( + ({ pageParam, page }) => ({ + params: { rest: pageParam ? `${fullPath}/${pageParam}` : fullPath }, + props: { + dest, + posts: page.data, + currentPage: page.currentPage, + lastPage: page.lastPage, + total: page.total, + prevUrl: page.url.prev, + nextUrl: page.url.next, + }, + }), + ); + }); +} + +const { dest, posts, currentPage, lastPage, total, prevUrl, nextUrl } = + Astro.props as Props; + +const page = { + currentPage, + lastPage, + url: { prev: prevUrl, next: nextUrl }, +}; + +const metadata = { + title: dest.name, + description: dest.description ?? `Khám phá các bài viết về ${dest.name}.`, +}; +--- + + +
    +
    + + + + +
    + {posts.map((post) => )} +
    + +
    +
    +
    diff --git a/src/pages/destination/[destination]/[...page].astro b/src/pages/destination/[destination]/[...page].astro deleted file mode 100644 index bc948cf..0000000 --- a/src/pages/destination/[destination]/[...page].astro +++ /dev/null @@ -1,64 +0,0 @@ ---- -import BaseLayout from "@/layouts/BaseLayout.astro"; -import { getCollection } 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 type { GetStaticPathsOptions } from "astro"; - -export async function getStaticPaths({ paginate }: GetStaticPathsOptions) { - const allPosts = await getCollection("blog"); - const posts = allPosts.filter((post) => post.data && post.data.pubDate); - - const destinations = new Set(); - posts.forEach((post) => { - post.data.destination?.forEach((d) => destinations.add(d)); - }); - - return Array.from(destinations).flatMap((destination) => { - const destinationPosts = posts - .filter((post) => post.data.destination?.includes(destination)) - .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()); - - return paginate(destinationPosts, { - params: { destination }, - pageSize: 6, - }); - }); -} - -const { page } = Astro.props; -const { destination } = Astro.params; - -const metadata = { - title: `Destination: ${destination}`, - description: `Travel articles about ${destination}.`, -}; ---- - - -
    -
    - - - - -
    - {page.data.map((post) => )} -
    - -
    -
    -
    diff --git a/src/pages/tag/[tag]/[...page].astro b/src/pages/tag/[tag]/[...page].astro index bffeb93..563bb47 100644 --- a/src/pages/tag/[tag]/[...page].astro +++ b/src/pages/tag/[tag]/[...page].astro @@ -1,12 +1,24 @@ --- 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 type { GetStaticPathsOptions } from "astro"; +import { getTaxonomyMap } from "@/utils/taxonomy"; +import { buildPaginatedPaths } from "@/utils/paginate"; -export async function getStaticPaths({ paginate }: GetStaticPathsOptions) { +interface Props { + page: { + data: CollectionEntry<"blog">[]; + total: number; + currentPage: number; + lastPage: number; + url: { prev?: string; next?: string }; + }; +} + +export async function getStaticPaths() { const allPosts = await getCollection("blog"); const posts = allPosts.filter((post) => post.data && post.data.pubDate); @@ -20,19 +32,25 @@ export async function getStaticPaths({ paginate }: GetStaticPathsOptions) { .filter((post) => post.data.tags?.includes(tag)) .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()); - return paginate(tagPosts, { - params: { tag }, - pageSize: 6, - }); + return buildPaginatedPaths(tagPosts, `/tag/${tag}`).map( + ({ pageParam, page }) => ({ + params: { tag, page: pageParam }, + props: { page }, + }), + ); }); } -const { page } = Astro.props; +const { page } = Astro.props as Props; const { tag } = Astro.params; +const tagInfo = getTaxonomyMap("tag").get(tag!); +const displayName = tagInfo?.name ?? tag!; +const description = tagInfo?.description; + const metadata = { - title: `Posts tagged with '${tag}'`, - description: `Explore our articles about ${tag}.`, + title: displayName, + description: description ?? `Các bài viết được gắn thẻ ${displayName}.`, }; --- @@ -41,8 +59,8 @@ const metadata = {
    ( + items: T[], + basePath: string, + pageSize = DEFAULT_PAGE_SIZE, +) { + const totalPages = Math.max(1, Math.ceil(items.length / pageSize)); + + return Array.from({ length: totalPages }, (_, i) => { + const pageNum = i + 1; + const data = items.slice(i * pageSize, (i + 1) * pageSize); + + const prev = + pageNum === 1 + ? undefined + : pageNum === 2 + ? basePath + : `${basePath}/page/${pageNum - 1}`; + const next = + pageNum === totalPages ? undefined : `${basePath}/page/${pageNum + 1}`; + + return { + // page param: undefined for page 1 (matches the base URL), "page/N" for N≥2 + pageParam: pageNum === 1 ? undefined : `page/${pageNum}`, + page: { + data, + total: items.length, + currentPage: pageNum, + lastPage: totalPages, + url: { prev, next }, + } as PageMeta & { data: T[] }, + }; + }); +} diff --git a/src/utils/taxonomy.ts b/src/utils/taxonomy.ts new file mode 100644 index 0000000..9c80861 --- /dev/null +++ b/src/utils/taxonomy.ts @@ -0,0 +1,79 @@ +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; + +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; + 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 = { + 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 { + 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); +}