Skip to content

Commit 56495fc

Browse files
committed
feat: implement blog functionality
1 parent d862c5e commit 56495fc

File tree

9 files changed

+260
-10
lines changed

9 files changed

+260
-10
lines changed

bun.lock

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"dependencies": {
77
"@icons-pack/react-simple-icons": "^13.7.0",
88
"@shikijs/types": "^3.11.0",
9+
"feed": "^5.1.0",
910
"fumadocs-core": "15.8.4",
1011
"fumadocs-mdx": "12.0.3",
1112
"fumadocs-ui": "15.8.4",
@@ -832,6 +833,8 @@
832833

833834
"fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
834835

836+
"feed": ["[email protected]", "", { "dependencies": { "xml-js": "^1.6.11" } }, "sha512-qGNhgYygnefSkAHHrNHqC7p3R8J0/xQDS/cYUud8er/qD9EFGWyCdUDfULHTJQN1d3H3WprzVwMc9MfB4J50Wg=="],
837+
835838
"file-entry-cache": ["[email protected]", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
836839

837840
"fill-range": ["[email protected]", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
@@ -1358,6 +1361,8 @@
13581361

13591362
"safer-buffer": ["[email protected]", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
13601363

1364+
"sax": ["[email protected]", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="],
1365+
13611366
"scheduler": ["[email protected]", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
13621367

13631368
"scroll-into-view-if-needed": ["[email protected]", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ=="],
@@ -1524,6 +1529,8 @@
15241529

15251530
"word-wrap": ["[email protected]", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
15261531

1532+
"xml-js": ["[email protected]", "", { "dependencies": { "sax": "^1.2.4" }, "bin": { "xml-js": "./bin/cli.js" } }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="],
1533+
15271534
"yallist": ["[email protected]", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
15281535

15291536
"yocto-queue": ["[email protected]", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"dependencies": {
1313
"@icons-pack/react-simple-icons": "^13.7.0",
1414
"@shikijs/types": "^3.11.0",
15+
"feed": "^5.1.0",
1516
"fumadocs-core": "15.8.4",
1617
"fumadocs-mdx": "12.0.3",
1718
"fumadocs-ui": "15.8.4",

source.config.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1-
import { defineConfig, defineDocs, frontmatterSchema, metaSchema } from "fumadocs-mdx/config"
1+
import {
2+
defineConfig,
3+
defineDocs,
4+
defineCollections,
5+
frontmatterSchema,
6+
metaSchema,
7+
} from "fumadocs-mdx/config"
28
import { transformerCommandColor } from "./src/lib/command-transformer"
39
import { remarkMdxMermaid } from "fumadocs-core/mdx-plugins"
10+
import { z } from "zod"
411

512
// You can customise Zod schemas for frontmatter and `meta.json` here
613
// see https://fumadocs.vercel.app/docs/mdx/collections#define-docs
@@ -13,6 +20,16 @@ export const docs = defineDocs({
1320
},
1421
})
1522

23+
export const blogPosts = defineCollections({
24+
type: "doc",
25+
dir: "content/blog",
26+
schema: frontmatterSchema.extend({
27+
author: z.string(),
28+
date: z.iso.date().or(z.date()),
29+
category: z.enum(["devlog", "updates", "other"]),
30+
}),
31+
})
32+
1633
export default defineConfig({
1734
mdxOptions: {
1835
rehypeCodeOptions: {
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { blog } from "@/lib/source"
2+
import { getMDXComponents } from "@/mdx-components"
3+
import { PathUtils } from "fumadocs-core/source"
4+
import { InlineTOC } from "fumadocs-ui/components/inline-toc"
5+
import { notFound } from "next/navigation"
6+
7+
export default async function Page(props: PageProps<"/blog/[slug]">) {
8+
const params = await props.params
9+
const page = blog.getPage([params.slug])
10+
if (!page) notFound()
11+
const { body: Mdx, toc } = page.data
12+
13+
return (
14+
<>
15+
<div className="container mx-auto mt-12 space-y-10">
16+
<div className="mx-auto flex w-full flex-col gap-4 px-8">
17+
<h1 className="text-5xl font-semibold tracking-tight text-white">{page?.data.title}</h1>
18+
<p className="text-xl text-neutral-500">{page?.data.description}</p>
19+
20+
<div className="flex flex-row gap-8 mt-4 border border-white/20 p-4">
21+
<div className="flex flex-col gap-1">
22+
<p className="text-xs uppercase tracking-wide text-neutral-500">Author</p>
23+
<p className="font-medium">{page.data.author}</p>
24+
</div>
25+
26+
<div className="flex flex-col gap-1">
27+
<p className="text-xs uppercase tracking-wide text-neutral-500">Published</p>
28+
<p className="font-medium">
29+
{new Date(
30+
page.data.date ?? PathUtils.basename(page.path, PathUtils.extname(page.path)),
31+
).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
32+
</p>
33+
</div>
34+
</div>
35+
</div>
36+
</div>
37+
<article className="flex flex-col mx-auto w-full container gap-16 px-8 py-8 lg:flex-row">
38+
<div className="prose min-w-0 flex-1 p-4">
39+
<InlineTOC items={toc} className="rounded-none mb-8" />
40+
41+
<Mdx components={getMDXComponents()} />
42+
</div>
43+
</article>
44+
</>
45+
)
46+
}

src/app/(home)/blog/page.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"use server"
2+
3+
import { blog } from "@/lib/source"
4+
import { PathUtils } from "fumadocs-core/source"
5+
import { BlogSidebar } from "@/components/blog/blog-sidebar"
6+
7+
function getName(path: string) {
8+
return PathUtils.basename(path, PathUtils.extname(path))
9+
}
10+
11+
export default async function Blog() {
12+
const posts = [...blog.getPages()].sort(
13+
(a, b) =>
14+
new Date(b.data.date ?? getName(b.path)).getTime() -
15+
new Date(a.data.date ?? getName(a.path)).getTime(),
16+
)
17+
18+
const serializablePosts = posts.map((p) => ({
19+
path: p.path,
20+
data: {
21+
title: p.data.title,
22+
description: p.data.description,
23+
category: p.data.category,
24+
url: p.url,
25+
date:
26+
(typeof p.data.date === "string" ? p.data.date : p.data.date?.toISOString()) ?? undefined,
27+
},
28+
}))
29+
30+
return (
31+
<>
32+
<BlogSidebar posts={serializablePosts} />
33+
</>
34+
)
35+
}

src/app/rss.xml/route.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { getRSS } from "@/lib/rss"
2+
3+
export const revalidate = false
4+
5+
export function GET() {
6+
return new Response(getRSS())
7+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"use client"
2+
3+
import Link from "next/link"
4+
import { useState } from "react"
5+
import { BookOpen, Code, RotateCcw, FileText, File } from "lucide-react"
6+
7+
type BlogPost = {
8+
path: string
9+
data: {
10+
title: string
11+
description?: string
12+
category?: "devlog" | "updates" | "other"
13+
date?: string | Date
14+
url: string
15+
}
16+
}
17+
18+
type BlogSidebarProps = {
19+
posts: BlogPost[]
20+
}
21+
22+
export function BlogSidebar({ posts }: BlogSidebarProps) {
23+
const categories = [
24+
"All Posts",
25+
...[...new Set(posts.map((post) => post.data.category))].filter(
26+
(category): category is "devlog" | "updates" | "other" => Boolean(category),
27+
),
28+
]
29+
30+
const [selectedCategory, setSelectedCategory] = useState("All Posts")
31+
32+
const getCategoryIcon = (category: string) => {
33+
switch (category) {
34+
case "All Posts":
35+
return <BookOpen className="w-4 h-4" />
36+
case "devlog":
37+
return <Code className="w-4 h-4" />
38+
case "updates":
39+
return <RotateCcw className="w-4 h-4" />
40+
case "other":
41+
return <FileText className="w-4 h-4" />
42+
default:
43+
return <File className="w-4 h-4" />
44+
}
45+
}
46+
47+
const filteredPosts =
48+
selectedCategory === "All Posts"
49+
? posts
50+
: posts.filter((post) => post.data.category === selectedCategory)
51+
52+
return (
53+
<main className="container mx-auto my-12 space-y-10">
54+
<div className="mx-auto flex w-full gap-16 px-8">
55+
<aside className="w-48 flex-shrink-0">
56+
<h1 className="text-5xl font-semibold tracking-tight">Blog</h1>
57+
<nav className="mt-12 flex flex-col space-y-4 text-sm uppercase text-neutral-500">
58+
{categories.map((category) => (
59+
<button
60+
key={category}
61+
onClick={() => setSelectedCategory(category)}
62+
className={`flex items-center gap-2 text-left transition-colors hover:text-white uppercase text-md ${
63+
selectedCategory === category ? "text-white" : ""
64+
}`}
65+
>
66+
{getCategoryIcon(category)}
67+
{category}
68+
</button>
69+
))}
70+
</nav>
71+
</aside>
72+
<div className="flex flex-1 flex-col space-y-6 mt-22">
73+
{filteredPosts.map((post) => (
74+
<Link href={post.data.url} key={post.path} className="border border-white/20 p-4">
75+
<article className="group">
76+
<div className="flex flex-col space-y-3 p-6 transition-colors group-hover:border-white/20">
77+
<h2 className="text-2xl font-semibold text-white group-hover:text-white/90">
78+
{post.data.title}
79+
</h2>
80+
<p className="text-sm text-neutral-400">{post.data.description}</p>
81+
<p className="text-xs uppercase tracking-wide text-neutral-500 flex items-center gap-2">
82+
{getCategoryIcon(post.data.category ?? "All Posts")} {post.data.category} ·{" "}
83+
{new Date(
84+
(typeof post.data.date === "string"
85+
? post.data.date
86+
: post.data.date?.toISOString()) ??
87+
post.path.split("/").pop()?.replace(".mdx", "") ??
88+
"",
89+
).toLocaleDateString("en-US", {
90+
month: "short",
91+
day: "numeric",
92+
year: "numeric",
93+
})}
94+
</p>
95+
</div>
96+
</article>
97+
</Link>
98+
))}
99+
</div>
100+
</div>
101+
</main>
102+
)
103+
}

src/lib/rss.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Feed } from "feed"
2+
import { blog } from "@/lib/source"
3+
4+
const baseUrl = "https://thenextlvl.net"
5+
6+
export function getRSS() {
7+
const feed = new Feed({
8+
title: "TheNextLvl Blog",
9+
id: `${baseUrl}/blog`,
10+
link: `${baseUrl}/blog`,
11+
language: "en",
12+
13+
image: `${baseUrl}/banner.png`,
14+
favicon: `${baseUrl}/icon.png`,
15+
copyright: "All rights reserved 2025, TheNextLvl",
16+
})
17+
18+
for (const page of blog.getPages()) {
19+
feed.addItem({
20+
id: page.url,
21+
title: page.data.title,
22+
description: page.data.description,
23+
link: `${baseUrl}${page.url}`,
24+
date: new Date(page.data.lastModified?.getDate() ?? new Date()),
25+
26+
author: [
27+
{
28+
name: "TheNextLvl",
29+
},
30+
],
31+
})
32+
}
33+
34+
return feed.rss2()
35+
}

src/lib/source.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
1-
import { docs } from "@/.source"
1+
import { docs, blogPosts } from "@/.source"
22
import { loader } from "fumadocs-core/source"
3-
import { createElement } from "react"
4-
import { icons } from "lucide-react"
3+
import { lucideIconsPlugin } from "fumadocs-core/source/lucide-icons"
4+
import { createMDXSource } from "fumadocs-mdx/runtime/next"
55
export const source = loader({
66
baseUrl: "/docs",
77
source: docs.toFumadocsSource(),
8-
icon(icon) {
9-
if (!icon) {
10-
return
11-
}
12-
if (icon in icons) return createElement(icons[icon as keyof typeof icons])
13-
},
8+
plugins: [lucideIconsPlugin()],
9+
})
10+
11+
export const blog = loader(createMDXSource(blogPosts), {
12+
baseUrl: "/blog",
1413
})

0 commit comments

Comments
 (0)