feat: allow blog posts to be organized by subdirectories

Blog posts can now be structured into subdirectories, which determine their slugs.

Example:
- `/src/data/blog/2025/testing.md` → `/posts/2025/testing`

Directories prefixed with `_` will be excluded from the slug.

Example:
- `/src/data/blog/_examples/tailwind-typography.md` → `/posts/tailwind-typography`

Closes #470, #366
This commit is contained in:
satnaing
2025-03-16 20:09:03 +07:00
committed by Sat Naing
parent 67b9536cce
commit feaf5e122a
19 changed files with 64 additions and 39 deletions
+5 -6
View File
@@ -1,17 +1,16 @@
--- ---
import { slugifyStr } from "@/utils/slugify"; import { slugifyStr } from "@/utils/slugify";
import type { CollectionEntry } from "astro:content"; import type { CollectionEntry } from "astro:content";
import { getPath } from "@/utils/getPath";
import Datetime from "./Datetime.astro"; import Datetime from "./Datetime.astro";
export interface Props { export interface Props extends CollectionEntry<"blog"> {
variant?: "h2" | "h3"; variant?: "h2" | "h3";
href?: string;
frontmatter: CollectionEntry<"blog">["data"];
} }
const { href, frontmatter, variant = "h2" } = Astro.props; const { variant = "h2", data, id, filePath } = Astro.props;
const { title, pubDatetime, modDatetime, description } = frontmatter; const { title, pubDatetime, modDatetime, description } = data;
const headerProps = { const headerProps = {
style: { viewTransitionName: slugifyStr(title) }, style: { viewTransitionName: slugifyStr(title) },
@@ -21,7 +20,7 @@ const headerProps = {
<li class="my-6"> <li class="my-6">
<a <a
href={href} href={getPath(id, filePath)}
class="inline-block text-lg font-medium text-accent decoration-dashed underline-offset-4 focus-visible:no-underline focus-visible:underline-offset-0" class="inline-block text-lg font-medium text-accent decoration-dashed underline-offset-4 focus-visible:no-underline focus-visible:underline-offset-0"
> >
{ {
+1 -1
View File
@@ -12,7 +12,7 @@ export const SITE = {
showArchives: true, showArchives: true,
showBackButton: true, // show back button in post detail showBackButton: true, // show back button in post detail
editPost: { editPost: {
url: "https://github.com/satnaing/astro-paper/edit/main/src/content/blog", url: "https://github.com/satnaing/astro-paper/edit/main/src/data/blog",
text: "Suggest Changes", text: "Suggest Changes",
appendFilePath: true, appendFilePath: true,
}, },
+3 -1
View File
@@ -2,8 +2,10 @@ import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders"; import { glob } from "astro/loaders";
import { SITE } from "@/config"; import { SITE } from "@/config";
export const BLOG_PATH = "src/data/blog";
const blog = defineCollection({ const blog = defineCollection({
loader: glob({ pattern: "**/[^_]*.md", base: "./src/data/blog" }), loader: glob({ pattern: "**/[^_]*.md", base: `./${BLOG_PATH}` }),
schema: ({ image }) => schema: ({ image }) =>
z.object({ z.object({
author: z.string().default(SITE.author), author: z.string().default(SITE.author),
@@ -4,7 +4,7 @@ pubDatetime: 2024-01-04T09:30:41.816Z
title: AstroPaper 4.0 title: AstroPaper 4.0
slug: "astro-paper-v4" slug: "astro-paper-v4"
featured: false featured: false
ogImage: ../../assets/images/AstroPaper-v4.png ogImage: ../../../assets/images/AstroPaper-v4.png
tags: tags:
- release - release
description: "AstroPaper v4: ensuring a smoother and more feature-rich blogging experience." description: "AstroPaper v4: ensuring a smoother and more feature-rich blogging experience."
@@ -3,7 +3,7 @@ pubDatetime: 2025-03-08T08:18:19.693Z
title: AstroPaper 5.0 title: AstroPaper 5.0
slug: astro-paper-v5 slug: astro-paper-v5
featured: true featured: true
ogImage: ../../assets/images/AstroPaper-v5.png ogImage: ../../../assets/images/AstroPaper-v5.png
tags: tags:
- release - release
description: "AstroPaper v5: keep the clean look, updates under the hood." description: "AstroPaper v5: keep the clean look, updates under the hood."
+2 -1
View File
@@ -8,6 +8,7 @@ import Datetime from "@/components/Datetime.astro";
import EditPost from "@/components/EditPost.astro"; import EditPost from "@/components/EditPost.astro";
import ShareLinks from "@/components/ShareLinks.astro"; import ShareLinks from "@/components/ShareLinks.astro";
import BackButton from "@/components/BackButton.astro"; import BackButton from "@/components/BackButton.astro";
import { getPath } from "@/utils/getPath";
import { slugifyStr } from "@/utils/slugify"; import { slugifyStr } from "@/utils/slugify";
import IconChevronLeft from "@/assets/icons/IconChevronLeft.svg"; import IconChevronLeft from "@/assets/icons/IconChevronLeft.svg";
import IconChevronRight from "@/assets/icons/IconChevronRight.svg"; import IconChevronRight from "@/assets/icons/IconChevronRight.svg";
@@ -45,7 +46,7 @@ if (typeof initOgImage === "string") {
// Use dynamic OG image if enabled and no remote|local ogImage // Use dynamic OG image if enabled and no remote|local ogImage
if (!ogImageUrl && SITE.dynamicOgImage) { if (!ogImageUrl && SITE.dynamicOgImage) {
ogImageUrl = `/posts/${slugifyStr(title)}/index.png`; ogImageUrl = `${getPath(post.id, post.filePath)}/index.png`;
} }
// Resolve OG image URL (or fallback to SITE.ogImage / default `og.png`) // Resolve OG image URL (or fallback to SITE.ogImage / default `og.png`)
+2 -2
View File
@@ -69,8 +69,8 @@ const months = [
new Date(a.data.pubDatetime).getTime() / 1000 new Date(a.data.pubDatetime).getTime() / 1000
) )
) )
.map(({ data, id }) => ( .map(data => (
<Card href={`/posts/${id}`} frontmatter={data} /> <Card {...data} />
))} ))}
</ul> </ul>
</div> </div>
+4 -10
View File
@@ -76,8 +76,8 @@ const recentPosts = sortedPosts.filter(({ data }) => !data.featured);
<section id="featured" class="pt-12 pb-6"> <section id="featured" class="pt-12 pb-6">
<h2 class="text-2xl font-semibold tracking-wide">Featured</h2> <h2 class="text-2xl font-semibold tracking-wide">Featured</h2>
<ul> <ul>
{featuredPosts.map(({ data, id }) => ( {featuredPosts.map(data => (
<Card href={`/posts/${id}/`} frontmatter={data} variant="h3" /> <Card variant="h3" {...data} />
))} ))}
</ul> </ul>
</section> </section>
@@ -92,14 +92,8 @@ const recentPosts = sortedPosts.filter(({ data }) => !data.featured);
<h2 class="text-2xl font-semibold tracking-wide">Recent Posts</h2> <h2 class="text-2xl font-semibold tracking-wide">Recent Posts</h2>
<ul> <ul>
{recentPosts.map( {recentPosts.map(
({ data, id }, index) => (data, index) =>
index < SITE.postPerIndex && ( index < SITE.postPerIndex && <Card variant="h3" {...data} />
<Card
href={`/posts/${id}/`}
frontmatter={data}
variant="h3"
/>
)
)} )}
</ul> </ul>
</section> </section>
+1 -5
View File
@@ -22,11 +22,7 @@ const { page } = Astro.props;
<Header /> <Header />
<Main pageTitle="Posts" pageDesc="All the articles I've posted."> <Main pageTitle="Posts" pageDesc="All the articles I've posted.">
<ul> <ul>
{ {page.data.map(data => <Card {...data} />)}
page.data.map(({ data, id }) => (
<Card href={`/posts/${id}`} frontmatter={data} />
))
}
</ul> </ul>
</Main> </Main>
@@ -2,6 +2,7 @@
import { type CollectionEntry, getCollection } from "astro:content"; import { type CollectionEntry, getCollection } from "astro:content";
import PostDetails from "@/layouts/PostDetails.astro"; import PostDetails from "@/layouts/PostDetails.astro";
import getSortedPosts from "@/utils/getSortedPosts"; import getSortedPosts from "@/utils/getSortedPosts";
import { getPath } from "@/utils/getPath";
export interface Props { export interface Props {
post: CollectionEntry<"blog">; post: CollectionEntry<"blog">;
@@ -9,9 +10,8 @@ export interface Props {
export async function getStaticPaths() { export async function getStaticPaths() {
const posts = await getCollection("blog", ({ data }) => !data.draft); const posts = await getCollection("blog", ({ data }) => !data.draft);
const postResult = posts.map(post => ({ const postResult = posts.map(post => ({
params: { slug: post.id }, params: { slug: getPath(post.id, post.filePath, false) },
props: { post }, props: { post },
})); }));
@@ -1,7 +1,7 @@
import type { APIRoute } from "astro"; import type { APIRoute } from "astro";
import { getCollection, type CollectionEntry } from "astro:content"; import { getCollection, type CollectionEntry } from "astro:content";
import { getPath } from "@/utils/getPath";
import { generateOgImageForPost } from "@/utils/generateOgImages"; import { generateOgImageForPost } from "@/utils/generateOgImages";
import { slugifyStr } from "@/utils/slugify";
import { SITE } from "@/config"; import { SITE } from "@/config";
export async function getStaticPaths() { export async function getStaticPaths() {
@@ -14,7 +14,7 @@ export async function getStaticPaths() {
); );
return posts.map(post => ({ return posts.map(post => ({
params: { slug: slugifyStr(post.data.title) }, params: { slug: getPath(post.id, post.filePath, false) },
props: post, props: post,
})); }));
} }
+3 -2
View File
@@ -1,5 +1,6 @@
import rss from "@astrojs/rss"; import rss from "@astrojs/rss";
import { getCollection } from "astro:content"; import { getCollection } from "astro:content";
import { getPath } from "@/utils/getPath";
import getSortedPosts from "@/utils/getSortedPosts"; import getSortedPosts from "@/utils/getSortedPosts";
import { SITE } from "@/config"; import { SITE } from "@/config";
@@ -10,8 +11,8 @@ export async function GET() {
title: SITE.title, title: SITE.title,
description: SITE.desc, description: SITE.desc,
site: SITE.website, site: SITE.website,
items: sortedPosts.map(({ data, id }) => ({ items: sortedPosts.map(({ data, id, filePath }) => ({
link: `posts/${id}/`, link: getPath(id, filePath),
title: data.title, title: data.title,
description: data.description, description: data.description,
pubDate: new Date(data.modDatetime ?? data.pubDatetime), pubDate: new Date(data.modDatetime ?? data.pubDatetime),
+1 -5
View File
@@ -40,11 +40,7 @@ const { page, tagName } = Astro.props;
> >
<h1 slot="title" transition:name={tag}>{`Tag:${tag}`}</h1> <h1 slot="title" transition:name={tag}>{`Tag:${tag}`}</h1>
<ul> <ul>
{ {page.data.map(data => <Card {...data} />)}
page.data.map(({ data, id }) => (
<Card href={`/posts/${id}`} frontmatter={data} />
))
}
</ul> </ul>
</Main> </Main>
+36
View File
@@ -0,0 +1,36 @@
import { BLOG_PATH } from "@/content.config";
import { slugifyStr } from "./slugify";
/**
* Get full path of a blog post
* @param id - id of the blog post (aka slug)
* @param filePath - the blog post full file location
* @param includeBase - whether to include `/posts` in return value
* @returns blog post path
*/
export function getPath(
id: string,
filePath: string | undefined,
includeBase = true
) {
const pathSegments = filePath
?.replace(BLOG_PATH, "")
.split("/")
.filter(path => path !== "") // remove empty string in the segments ["", "other-path"] <- empty string will be removed
.filter(path => !path.startsWith("_")) // exclude directories start with underscore "_"
.slice(0, -1) // remove the last segment_ file name_ since it's unnecessary
.map(segment => slugifyStr(segment)); // slugify each segment path
const basePath = includeBase ? "/posts" : "";
// Making sure `id` does not contain the directory
const blogId = id.split("/");
const slug = blogId.length > 0 ? blogId.slice(-1) : blogId;
// If not inside the sub-dir, simply return the file path
if (!pathSegments || pathSegments.length < 1) {
return [basePath, slug].join("/");
}
return [basePath, ...pathSegments, slug].join("/");
}