();
- 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);
+}