Introduction
It took me a while to understand content collections with dynamic pages. The documentation is helpful, and I assume you have gone through it. However, it does not cover everything.
The code of this website likely has changed, and I likely won't update this guide if it still functions. It could help to get you started.
Content collections
The content collections "category" and "post" are used within the following sections of code.
import { z, defineCollection } from 'astro:content';
export const collections = {
category: defineCollection({
type: 'data',
schema: z.object({
title: z.string({
required_error: "Required frontmatter missing: title",
invalid_type_error: "title must be a string",
}),
description: z.optional(z.string()),
homeWeight: z.optional(z.number()),
addToRecentlyUpdated: z.boolean().default(true)
})
}),
post: defineCollection({
type: 'content',
schema: z.object({
draft: z.boolean().default(false),
title: z.string({
required_error: "Required frontmatter missing: title",
invalid_type_error: "title must be a string",
}),
date: z.date({
required_error: "Required frontmatter missing: date",
invalid_type_error:
"date must be written in yyyy-mm-dd format without quotes: For example, Jan 22, 2000 should be written as 2000-01-22.",
}),
description: z.optional(z.string()),
ogImagePath: z.optional(z.string()),
canonicalUrl: z.optional(z.string()),
tags: z.optional(z.array(z.string())),
weight: z.number()
}),
})
};
Dynamic pages
I use two dynamic pages at the root of the pages directory for my categories. AstroJS knows that something can be endlessly nested by the ...
operator. FilterAndSortPosts
is a custom function to exclude drafts and sort the posts. It is rather easy to write your own function or directly use originalPosts.
One important thing I did not realize at first is that it is dangerous to modify the fetched posts directly. The spread operator, or cloning should be used instead. The elements within fetched collections can be mutated, but doing so can result in unexpected behaviour.
// [...slug].astro
export async function getStaticPaths() {
// The objects of these fetches shouldn't be mutated
const originalPosts = await getCollection("post");
return filterAndSortPosts(originalPosts)
.map((originalPost) => {
const categories = originalPost.slug.split("/");
// Create a new object with the necessary modifications
const modifiedPost = {
...originalPost,
data: { ...originalPost.data, isArticle: true, },
};
return {
params: {
category: categories[categories.length - 1],
slug: originalPost.slug,
},
props: { post: modifiedPost },
};
});
}
const { post } = Astro.props;
const { Content, headings } = await post.render();
// [...category].astro
export async function getStaticPaths() {
// The objects of these fetches shouldn't be mutated
const originalPosts = await getCollection("post");
const originalCategories = await getCollection("category");
const categoryObjects = filterAndSortPosts(originalPosts)
.map((originalPost) => {
const categories = originalPost.slug.split("/");
const post = categories.pop();
return { categories, post };
});
const tree = buildCategoryTree(categoryObjects, originalPosts);
return generateCategoryPages(tree, originalCategories);
}
const { category, posts } = Astro.props;
const type = { category: category, posts: posts };
The subcategory logic
This part that likely may interest you – the function that creates a tree of categories and maps the correct posts, etc. I am not sure if the global variables have any effect; nevertheless, the output is okay.
let categoryTree = null;
let categoryPages = null;
export function buildCategoryTree(categories, posts) {
if (categoryTree != null) // only build tree once
return categoryTree;
categoryTree = {};
categories.forEach(({ categories, post }) => {
let currentLevel = categoryTree;
categories.forEach((category, index) => {
if (!currentLevel[category])
currentLevel[category] = {};
if (index === categories.length - 1) {
// Last category in the path
if (!currentLevel[category].posts)
currentLevel[category].posts = new Array();
const postToAdd = posts.find((p) => p.slug.endsWith(`${category}/${post}`));
if (postToAdd)
currentLevel[category].posts.push(postToAdd);
}
currentLevel = currentLevel[category];
});
});
return categoryTree;
}
export function generateCategoryPages(tree, categories) {
if (categoryPages != null) // only build pages once
return categoryPages;
const mapCategoryToPost = (category, slug) => ({ ...category, slug });
function processNode(categoryId, node, currentPath = '') {
// Update the path with the current category
const newPath = currentPath === '' ? categoryId : `${currentPath}/${categoryId}`;
const category = categories.find(c => c.id === categoryId);
const itemToAdd = {
params: { category: newPath },
props: { category, posts: [], },
};
let items = [];
if (node.posts && node.posts.length) {
itemToAdd.props.posts = itemToAdd.props.posts.concat(node.posts);
items.push(itemToAdd);
} else if (node) {
// Add subCategories
const subCategoriesIds = Object.keys(node);
const firstItem = { ...itemToAdd };
firstItem.props.posts = firstItem.props.posts.concat(
subCategoriesIds.map(subcategoryId => mapCategoryToPost(categories.find(c => c.id === subcategoryId), `${newPath}/${subcategoryId}`))
);
items.push(firstItem);
// Recursively process subcategories and collect the items
subCategoriesIds
.flatMap(id => processNode(id, node[id], newPath))
.forEach(subCategoryPost => items.push(subCategoryPost));
}
return items;
}
categoryPages = [];
// Process the root of the tree
Object
.keys(tree)
.forEach((rootCategory) => { categoryPages = categoryPages.concat(processNode(rootCategory, tree[rootCategory])); });
return categoryPages;
}
Content
It is possible to use Markdoc for the content. Only the deepest category has to be set. There are other formats that AstroJS can use, like org-mode, but this should be enough to get you started.
---
title: "Dynamic nested pages"
description: "Guide on how to use nested dynamic pages in AstroJS."
date: 2023-01-21
weight: 1
category: "astrojs"
---