mirror of
https://github.com/ChenQihan666/MyBlog-Next.git
synced 2026-08-14 11:01:42 +08:00
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:
@@ -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
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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."
|
||||||
@@ -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`)
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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("/");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user