2026-02-08 23:18:21 -05:00
|
|
|
import { getPostBySlug, getPostSlugs } from '@/lib/mdx';
|
2026-02-07 20:17:46 -05:00
|
|
|
import { MDXRemote } from 'next-mdx-remote/rsc';
|
|
|
|
|
import Link from 'next/link';
|
|
|
|
|
import { notFound } from 'next/navigation';
|
|
|
|
|
import { format } from 'date-fns';
|
|
|
|
|
import rehypeSlug from 'rehype-slug';
|
|
|
|
|
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
|
|
|
|
|
import { TableOfContents } from '@/components/mdx/TableOfContents';
|
|
|
|
|
import { SideNote } from '@/components/mdx/SideNote';
|
|
|
|
|
import { Citation, Bibliography } from '@/components/mdx/Citation';
|
|
|
|
|
import { MobileTableOfContents } from '@/components/mdx/MobileTableOfContents';
|
|
|
|
|
|
|
|
|
|
// Utility to ensure consistent IDs
|
2026-02-08 23:18:21 -05:00
|
|
|
const slugify = (text: React.ReactNode): string => {
|
2026-02-07 20:17:46 -05:00
|
|
|
if (!text) return '';
|
|
|
|
|
const str = typeof text === 'string' ? text : String(text);
|
|
|
|
|
return str
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
|
|
|
.replace(/^-+|-+$/g, '');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const components = {
|
2026-02-08 23:18:21 -05:00
|
|
|
h1: (props: React.ComponentPropsWithoutRef<'h1'>) => <h1 {...props} id={slugify(props.children)} className="text-3xl font-bold mt-8 mb-4 text-zinc-900 dark:text-zinc-50 scroll-mt-24" />,
|
|
|
|
|
h2: (props: React.ComponentPropsWithoutRef<'h2'>) => <h2 {...props} id={slugify(props.children)} className="text-2xl font-bold mt-8 mb-4 text-zinc-900 dark:text-zinc-50 scroll-mt-24" />,
|
|
|
|
|
h3: (props: React.ComponentPropsWithoutRef<'h3'>) => <h3 {...props} id={slugify(props.children)} className="text-xl font-bold mt-6 mb-3 text-zinc-900 dark:text-zinc-50 scroll-mt-24" />,
|
|
|
|
|
p: (props: React.ComponentPropsWithoutRef<'p'>) => <p {...props} className="mb-4 leading-relaxed text-zinc-700 dark:text-zinc-300" />,
|
|
|
|
|
ul: (props: React.ComponentPropsWithoutRef<'ul'>) => <ul {...props} className="list-disc pl-5 mb-4 space-y-2 text-zinc-700 dark:text-zinc-300" />,
|
|
|
|
|
ol: (props: React.ComponentPropsWithoutRef<'ol'>) => <ol {...props} className="list-decimal pl-5 mb-4 space-y-2 text-zinc-700 dark:text-zinc-300" />,
|
|
|
|
|
blockquote: (props: React.ComponentPropsWithoutRef<'blockquote'>) => <blockquote {...props} className="border-l-4 border-zinc-200 dark:border-zinc-700 pl-4 italic my-6 text-zinc-600 dark:text-zinc-400" />,
|
|
|
|
|
code: (props: React.ComponentPropsWithoutRef<'code'>) => <code {...props} className="bg-zinc-100 dark:bg-zinc-800 text-pink-600 dark:text-pink-400 px-1 py-0.5 rounded text-sm font-mono" />,
|
|
|
|
|
pre: (props: React.ComponentPropsWithoutRef<'pre'>) => <pre {...props} className="bg-zinc-900 dark:bg-zinc-900 text-zinc-100 p-4 rounded-lg overflow-x-auto my-6 text-sm font-mono" />,
|
|
|
|
|
a: (props: React.ComponentPropsWithoutRef<'a'>) => <a {...props} className="text-zinc-900 dark:text-zinc-100 underline decoration-zinc-300 dark:decoration-zinc-700 hover:decoration-zinc-900 dark:hover:decoration-zinc-100 transition-all font-medium" />,
|
2026-02-07 20:17:46 -05:00
|
|
|
SideNote,
|
|
|
|
|
Citation,
|
|
|
|
|
Bibliography,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type Props = {
|
|
|
|
|
params: Promise<{ slug: string }>;
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-08 23:18:21 -05:00
|
|
|
export function generateStaticParams() {
|
|
|
|
|
return getPostSlugs()
|
|
|
|
|
.filter((slug) => slug.endsWith('.mdx'))
|
|
|
|
|
.map((slug) => ({ slug: slug.replace(/\.mdx$/, '') }));
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-07 20:17:46 -05:00
|
|
|
export async function generateMetadata({ params }: Props) {
|
|
|
|
|
const { slug } = await params;
|
|
|
|
|
try {
|
|
|
|
|
const { metadata } = getPostBySlug(slug);
|
|
|
|
|
return {
|
|
|
|
|
title: metadata.title,
|
|
|
|
|
description: metadata.description,
|
2026-02-08 23:18:21 -05:00
|
|
|
openGraph: {
|
|
|
|
|
title: metadata.title,
|
|
|
|
|
description: metadata.description,
|
|
|
|
|
type: 'article' as const,
|
|
|
|
|
publishedTime: metadata.date,
|
|
|
|
|
},
|
|
|
|
|
twitter: {
|
|
|
|
|
card: 'summary' as const,
|
|
|
|
|
title: metadata.title,
|
|
|
|
|
description: metadata.description,
|
|
|
|
|
},
|
2026-02-07 20:17:46 -05:00
|
|
|
};
|
|
|
|
|
} catch {
|
|
|
|
|
return {
|
|
|
|
|
title: 'Post Not Found',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default async function BlogPost({ params }: Props) {
|
|
|
|
|
const { slug } = await params;
|
|
|
|
|
|
|
|
|
|
let post;
|
|
|
|
|
try {
|
|
|
|
|
post = getPostBySlug(slug);
|
|
|
|
|
} catch {
|
|
|
|
|
notFound();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Extract headings for TOC
|
|
|
|
|
const headingLines = post.content.match(/^#{2,3}\s+(.+)$/gm) || [];
|
|
|
|
|
const headings = headingLines.map((line) => {
|
|
|
|
|
const level = line.match(/^#+/)?.[0].length || 2;
|
|
|
|
|
const text = line.replace(/^#+\s+/, '').trim();
|
|
|
|
|
const id = slugify(text);
|
|
|
|
|
return { id, text, level };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="max-w-7xl mx-auto px-6 py-24 animate-fade-in relative">
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-1 xl:grid-cols-[250px_1fr] gap-12">
|
|
|
|
|
{/* Left Column: TOC */}
|
|
|
|
|
<aside className="hidden xl:block">
|
|
|
|
|
<div className="sticky top-32">
|
|
|
|
|
<TableOfContents headings={headings} />
|
|
|
|
|
</div>
|
|
|
|
|
</aside>
|
|
|
|
|
|
|
|
|
|
{/* Right Column: Content */}
|
|
|
|
|
<article className="max-w-3xl mx-auto xl:mx-0 w-full">
|
|
|
|
|
|
|
|
|
|
{/* Mobile TOC */}
|
|
|
|
|
<MobileTableOfContents headings={headings} />
|
|
|
|
|
|
|
|
|
|
<header className="mb-12 text-center sm:text-left">
|
|
|
|
|
<div className="flex items-center gap-3 text-sm text-zinc-400 dark:text-zinc-500 mb-4 justify-center sm:justify-start">
|
|
|
|
|
<time dateTime={post.metadata.date}>
|
|
|
|
|
{format(new Date(post.metadata.date), 'MMMM d, yyyy')}
|
|
|
|
|
</time>
|
|
|
|
|
<span className="w-1 h-1 rounded-full bg-zinc-300 dark:bg-zinc-700" />
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
{Array.isArray(post.metadata.tags) && post.metadata.tags.map((tag: string) => (
|
|
|
|
|
<span key={tag} className="text-xs uppercase tracking-wider">{tag}</span>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<h1 className="text-4xl sm:text-5xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50 mb-6 leading-tight">
|
|
|
|
|
{post.metadata.title}
|
|
|
|
|
</h1>
|
|
|
|
|
|
|
|
|
|
<p className="text-xl text-zinc-500 dark:text-zinc-400 font-light leading-relaxed">
|
|
|
|
|
{post.metadata.description}
|
|
|
|
|
</p>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
<div className="prose prose-zinc dark:prose-invert prose-lg max-w-none relative">
|
|
|
|
|
<MDXRemote
|
|
|
|
|
source={post.content}
|
|
|
|
|
components={components}
|
|
|
|
|
options={{
|
|
|
|
|
mdxOptions: {
|
|
|
|
|
rehypePlugins: [
|
|
|
|
|
[rehypeAutolinkHeadings, { behavior: 'wrap' }]
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="mt-16 pt-8 border-t border-zinc-200 dark:border-zinc-800 flex justify-between items-center text-sm text-zinc-500">
|
|
|
|
|
<Link href="/blog" className="hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors">
|
|
|
|
|
← Back to all posts
|
|
|
|
|
</Link>
|
|
|
|
|
<a href="#" className="hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors">
|
|
|
|
|
Scroll to top ↑
|
|
|
|
|
</a>
|
|
|
|
|
</div>
|
|
|
|
|
</article>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|