diff --git a/source.config.ts b/source.config.ts index a35628a..805f0be 100644 --- a/source.config.ts +++ b/source.config.ts @@ -1,10 +1,15 @@ -import { defineConfig, defineDocs } from 'fumadocs-mdx/config'; -import { metaSchema, pageSchema } from 'fumadocs-core/source/schema'; +import { + defineCollections, + defineConfig, + defineDocs, +} from "fumadocs-mdx/config"; +import { metaSchema, pageSchema } from "fumadocs-core/source/schema"; +import { z } from "zod"; // You can customize Zod schemas for frontmatter and `meta.json` here // see https://fumadocs.dev/docs/mdx/collections export const docs = defineDocs({ - dir: 'content/docs', + dir: "content/docs", docs: { schema: pageSchema, postprocess: { @@ -16,6 +21,16 @@ export const docs = defineDocs({ }, }); +export const blogPosts = defineCollections({ + type: "doc", + dir: "content/blog", + // add required frontmatter properties + schema: pageSchema.extend({ + author: z.string(), + date: z.string().date().or(z.date()), + }), +}); + export default defineConfig({ mdxOptions: { // MDX options diff --git a/src/app/blog/[slug]/page.tsx b/src/app/blog/[slug]/page.tsx new file mode 100644 index 0000000..6f8490f --- /dev/null +++ b/src/app/blog/[slug]/page.tsx @@ -0,0 +1,95 @@ +import { notFound } from "next/navigation"; +import Link from "next/link"; +import defaultMdxComponents from "fumadocs-ui/mdx"; +import { blog } from "@/lib/source"; +import { ChevronLeft, Calendar, User, Clock } from "lucide-react"; +import { calculateReadingTime } from "@/lib/utils/reading-time"; + +export default async function Page(props: { + params: Promise<{ slug: string }>; +}) { + const params = await props.params; + const page = blog.getPage([params.slug]); + + if (!page) notFound(); + + const text = await page.data.getText("raw"); + + const Mdx = page.data.body; + + const readingTime = calculateReadingTime(text); + + return ( + + {/* Navigation & Breadcrumbs */} + + + Back to Blog + + + {/* Header Section */} + + + {page.data.title} + + + {page.data.description} + + + + + + + {page.data.author} + + + + + + {new Date(page.data.date).toLocaleDateString("en-US", { + month: "long", + day: "numeric", + year: "numeric", + })} + + + + + {readingTime} + + + + + {/* Content Layout */} + + + + + + {/* Optional Sidebar (TOC or Newsletter) */} + {/* */} + + + ); +} + +export function generateStaticParams(): { slug: string }[] { + return blog.getPages().map((page) => ({ + slug: page.slugs[0], + })); +} diff --git a/src/app/blog/layout.tsx b/src/app/blog/layout.tsx new file mode 100644 index 0000000..c16b056 --- /dev/null +++ b/src/app/blog/layout.tsx @@ -0,0 +1,6 @@ +import { HomeLayout } from "fumadocs-ui/layouts/home"; +import { baseOptions } from "@/lib/layout.shared"; + +export default function Layout({ children }: LayoutProps<"/">) { + return {children}; +} diff --git a/src/app/blog/page.tsx b/src/app/blog/page.tsx new file mode 100644 index 0000000..1e6ac7a --- /dev/null +++ b/src/app/blog/page.tsx @@ -0,0 +1,59 @@ +import Link from "next/link"; +import { blog } from "@/lib/source"; +import { Calendar, ArrowRight } from "lucide-react"; + +export default function Blog() { + // Sort posts by date (descending) so the newest is always first + const posts = [...blog.getPages()].sort( + (a, b) => new Date(b.data.date).getTime() - new Date(a.data.date).getTime(), + ); + + return ( + + + + Writing & Insights + + + Thoughts on software, design, and building for the web. + + + + + {posts.map((post) => ( + + {/* Date Tag */} + + + {new Date(post.data.date).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} + + + {/* Title & Description */} + + + {post.data.title} + + + {post.data.description} + + + + {/* "Read More" Footer */} + + Read Article + + + + ))} + + + ); +} diff --git a/src/lib/source.ts b/src/lib/source.ts index a00a3fc..dafa50c 100644 --- a/src/lib/source.ts +++ b/src/lib/source.ts @@ -1,7 +1,8 @@ -import { docs } from 'collections/server'; -import { loader } from 'fumadocs-core/source'; -import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons'; -import { docsContentRoute, docsImageRoute, docsRoute } from './shared'; +import { blogPosts, docs } from "collections/server"; +import { loader } from "fumadocs-core/source"; +import { lucideIconsPlugin } from "fumadocs-core/source/lucide-icons"; +import { docsContentRoute, docsImageRoute, docsRoute } from "./shared"; +import { toFumadocsSource } from "fumadocs-mdx/runtime/server"; // See https://fumadocs.dev/docs/headless/source-api for more info export const source = loader({ @@ -10,28 +11,33 @@ export const source = loader({ plugins: [lucideIconsPlugin()], }); -export function getPageImage(page: (typeof source)['$inferPage']) { - const segments = [...page.slugs, 'image.png']; +export function getPageImage(page: (typeof source)["$inferPage"]) { + const segments = [...page.slugs, "image.png"]; return { segments, - url: `${docsImageRoute}/${segments.join('/')}`, + url: `${docsImageRoute}/${segments.join("/")}`, }; } -export function getPageMarkdownUrl(page: (typeof source)['$inferPage']) { - const segments = [...page.slugs, 'content.md']; +export function getPageMarkdownUrl(page: (typeof source)["$inferPage"]) { + const segments = [...page.slugs, "content.md"]; return { segments, - url: `${docsContentRoute}/${segments.join('/')}`, + url: `${docsContentRoute}/${segments.join("/")}`, }; } -export async function getLLMText(page: (typeof source)['$inferPage']) { - const processed = await page.data.getText('processed'); +export async function getLLMText(page: (typeof source)["$inferPage"]) { + const processed = await page.data.getText("processed"); return `# ${page.data.title} (${page.url}) ${processed}`; } + +export const blog = loader({ + baseUrl: "/blog", + source: toFumadocsSource(blogPosts, []), +}); diff --git a/src/lib/utils/reading-time.ts b/src/lib/utils/reading-time.ts new file mode 100644 index 0000000..954bcdf --- /dev/null +++ b/src/lib/utils/reading-time.ts @@ -0,0 +1,16 @@ +export function calculateReadingTime(content: string): string { + const WPM = 225; // Average adult reading speed + + // 1. Strip HTML tags and Markdown syntax (simplified) + const plainText = content + .replace(/[#*`~_\[\]()]/g, "") // Remove common MD symbols + .replace(/<[^>]*>/g, ""); // Remove HTML tags + + // 2. Count words + const words = plainText.trim().split(/\s+/).length; + + // 3. Calculate minutes + const minutes = Math.ceil(words / WPM); + + return `${minutes} min read`; +}
+ {page.data.description} +
+ Thoughts on software, design, and building for the web. +
+ {post.data.description} +