From e397a7a4968cf678ff57af3c810e3c93e436c25b Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Mon, 11 May 2026 08:53:07 +0200 Subject: [PATCH] Blog --- source.config.ts | 21 ++++++-- src/app/blog/[slug]/page.tsx | 95 +++++++++++++++++++++++++++++++++++ src/app/blog/layout.tsx | 6 +++ src/app/blog/page.tsx | 59 ++++++++++++++++++++++ src/lib/source.ts | 30 ++++++----- src/lib/utils/reading-time.ts | 16 ++++++ 6 files changed, 212 insertions(+), 15 deletions(-) create mode 100644 src/app/blog/[slug]/page.tsx create mode 100644 src/app/blog/layout.tsx create mode 100644 src/app/blog/page.tsx create mode 100644 src/lib/utils/reading-time.ts 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} + +
+
+ + +
+
+ + {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`; +}