Nextjs를 이용한 mdx 블로그 제작 및 깃허브 액션 배포
Nextjs를 이용한 mdx 블로그 제작 및 깃허브 액션 배포
Nextjs 프레임워크를 활용해, mdx 파일을 파싱하여 페이지를 만드는 정적 웹사이트 만들고 깃허브 페이지 액션을 통해 배포하는 프로젝트입니다.
Nextjs란 React를 활용한 반응형 웹 페이지를 서비스하는 웹사이트를 쉽게 만들게 해주는 프레임워크입니다. 빌드시에 정적 페이지를 미리 만들어두어 서버 부하를 덜고 빠른 서비스가 가능하게 해준다는 장점이 있습니다.
mdx는 md파일에서 jsx 문법을 사용하게 해주는 파일 확장자입니다. 기존의 마크다운 형식보다 더 다양한 표현이 가능합니다.
블로그는 보통 작성자 개인이 일방적으로 글을 작성하여 자신이 학습한 내용을 정리하고 사람들에게 전파한다는 특징이 있습니다. 이 때문에 작성자 본인이 글을 작성하고 배포하는 데에 불편한 점이 없다면 블로그가 정적사이트로 운용되어도 문제가 없습니다.
nextjs를 활용한 mdx 블로그는 nextjs의 정적사이트 서비스 기능 및 라우팅 기능을 활용하여 개발자가 작성한 mdx 파일을 하나의 블로그 포스트로써 서비스하는 블로그입니다.
개발자가 직접 웹 사이트를 구성함으로써 높은 자유도를 가지고, 마크다운 형식으로 글을 작성하기에 개발자에게 익숙하고 편리한 글 작성이 가능하며 온전히 통제 가능하다는 장점이 있습니다.
GitHub Pages를 통해 무료로 인터넷에 배포를 합니다. 깃허브 액션에서 Next.js 프로젝트를 자동으로 배포하는 설정이 있기 때문에 무료이면서 안정적인 환경에서 배포가 가능합니다.
블로그에서 사람들과 소통을 하게 해주는 댓글 기능도 깃허브의 Issues를 활용하여 댓글로 사용을 할 수 있습니다. (현재는 구현 계획 없음)
npx create-next-app@latest blog --yes
cd ./blog
code .npm install @next/mdx @types/mdx gray-matter next-mdx-remote// /next.config.ts
import createMDX from '@next/mdx'
/** @type {import('next').NextConfig} */
const nextConfig = {
// Configure `pageExtensions` to include markdown and MDX files
pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
// Optionally, add any other Next.js config below
}
const withMDX = createMDX({
// md 확장자 포함 설정
extension: /\.(md|mdx)$/,
})
// Merge MDX config with Next.js config
export default withMDX(nextConfig)// /mdx-components.tsx
import type { MDXComponents } from 'mdx/types'
const components: MDXComponents = {}
export function useMDXComponents(): MDXComponents {
return components
}// mdx 파일 타입
export interface Post {
category: string // 상위 폴더명
slug: string // 파일명
title: string // 파일 안에서 메타데이터 설정
date: string // ``
description: string // ``
content: string // 포스트 본문
}walkDir - 디렉토리 탐색하면서 mdx 파일 경로 수집loadPosts - 전체 mdx 파일 읽고서 해시맵에 캐싱, cachedPostsData: Map<string, Post[]>getAllPostsData - 전체 mdx 파일들에 대한 메타데이터 get 함수(포스트 내용 제외)getPostsByCategory - 특정 카테고리에 대한 mdx파일들 메타데이터 getgetPostData - 카테고리와 슬러그(파일제목)을 통해 포스트 내용 get/lib 폴더에 작성
// /lib/posts.ts
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
const postsDirectory = path.join(process.cwd(), 'content/posts')
export interface Post {
category: string
slug: string
title: string
date: string
description: string
content: string
}
function walkDir(dir: string, fileList: string[] = []) {
const parent = path.dirname(dir);
const targetName = path.basename(dir);
const realEntries = fs.readdirSync(parent, { withFileTypes: true });
const realMatch = realEntries.find(
(entry) => entry.isDirectory() && entry.name === targetName
);
const realDir = realMatch ? path.join(parent, realMatch.name) : dir;
const files = fs.readdirSync(realDir);
files.forEach((file) => {
const fullPath = path.join(realDir, file);
if (fs.statSync(fullPath).isDirectory()) {
walkDir(fullPath, fileList);
} else if (file.endsWith(".mdx")) {
fileList.push(fullPath);
}
});
return fileList;
}
const cachedPostsData: Map<string, Post[]> = new Map();
let isCacheLoaded = false;
function loadPosts(): void {
if (isCacheLoaded) return;
if (!fs.existsSync(postsDirectory)) return;
cachedPostsData.clear();
const filePaths = walkDir(postsDirectory);
for (const fullPath of filePaths) {
const fileContents = fs.readFileSync(fullPath, "utf8");
const { data, content } = matter(fileContents);
const slug = path.basename(fullPath).replace(/\.mdx$/, "");
const category = path.basename(path.dirname(fullPath)) === "posts" ?
"" : path.basename(path.dirname(fullPath));
const post: Post = {
category,
slug,
title: data.title,
date: data.date,
description: data.description,
content: content
};
if (!cachedPostsData.has(category)) {
cachedPostsData.set(category, []);
}
cachedPostsData.get(category)!.push(post);
}
// 각 카테고리별로 날짜 내림차순 정렬
for (const [category, posts] of cachedPostsData) {
posts.sort(
(a, b) =>
new Date(b.date).getTime() - new Date(a.date).getTime()
);
}
isCacheLoaded = process.env.NODE_ENV === 'production';
}
export function getAllPostsData(): Omit<Post, "content">[] {
if(!isCacheLoaded) {
loadPosts();
}
const allPostsData = Array.from(cachedPostsData.values()).flat().map((post) => {
const slug = post.slug;
const category = post.category; // 바로 상위 폴더 이름
if(category === "") return; // posts 폴더 직속 파일 무시
return {
category: category, // 상위 폴더명을 카테고리로 사용
slug, // 파일명
title: post.title, // 포스트 제목
date: post.date, // 작성일
description: post.description, // 포스트 설명
};
}).filter(Boolean) as Omit<Post, "content">[];
return allPostsData.sort((a, b) => (a.date < b.date ? 1 : -1));
}
// 카테고리 기반 글 메타데이터 불러오기
export function getPostsByCategory(category: string): Omit<Post, "content">[] {
if(!isCacheLoaded) {
loadPosts();
}
const postsInCategory = cachedPostsData.get(category);
if (!postsInCategory) {
return [];
}
return postsInCategory;
}
// 파일명 기반으로 content/{slug}.mdx 파일 가져오기
export function getPostData(category: string, slug: string): Post | null {
if(!isCacheLoaded) {
loadPosts();
}
const postsInCategory = cachedPostsData.get(category);
if (!postsInCategory) {
return null;
}
const post = postsInCategory.find((post) => post.slug === slug);
if (!post) {
return null;
}
return post;
}/content/posts/temp/temp.mdx 파일을 작성합니다.---
title: 'temp'
date: '2025-12-23'
description: '임시 포스트입니다'
---
> 인용문
\# 임시 페이지입니다
\## h2 태그
- ㅇㅅㅇ
```
printf("hello, world!!");
```*동적 세그먼트를 활용하여 카테고리/제목 기반 라우팅 구조를 사용합니다
// /app/[category]/[slug]/page.tsx
import { getPostData } from '@/lib/posts'
import { MDXRemote } from 'next-mdx-remote/rsc'
import { notFound } from 'next/navigation';
export default async function Post({ params }: { params: Promise<{ category: string, slug: string }> }) {
const { category, slug } = await params
const postData = getPostData(category, slug)
if(!postData) {
return notFound();
}
return (
<div className="markdown-body">
<MDXRemote
source={postData.content}
options={{
mdxOptions: {
remarkPlugins: [],
rehypePlugins: [],
},
}}
/>
</div>
)
}npm run dev를 실행하고 로컬호스트로 접속하여 페이지가 잘 동작하는지 확인합니다.http://localhost:3000/temp/temp (3000번 포트는 nextjs 기본 설정, 환경에 따라 다를 수 있음)<div class="markdown-body"><blockquote>
<p>인용문</p>
</blockquote>
<h1>임시 페이지입니다</h1>
<h2>h2 태그</h2>
<ul>
<li>ㅇㅅㅇ</li>
</ul></div>mdxremote가 자동으로 마크다운 양식에 따라 blockquote, h1 h2, ul.. 태그들을 만들어줍니다.
이에 대해 github css를 적용헤 깃허브에서 README파일을 보던 것처럼 같은 스타일로 보이게 할 것입니다.
github css 패키지 설치
npm install github-markdown-css// /app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
// 상단 import문에 github css 추가
import 'github-markdown-css/github-markdown-light.css'blog
├──app // 앱 라우팅 폴더
| ├──layout.tsx
| ├──global.css
│ └── [category]
| └── [slug]
| └── page.tsx
|
|──content // mdx 폴더
| └── temp // temp 카테고리
| └── temp.mdx // temp 포스트
├──lib
| └──posts.ts // mdx 불러오는 유틸리티
├──next.config.ts // nextjs 설정
...
상단에 포스트 메타데이터 title, date를 표시하고, 회색 배경을 만들어주는 컨테이너 컴포넌트를 만들어줍니다.
// components/BlogPost.tsx
import type { Post } from '@/lib/posts';
import { MDXRemote } from 'next-mdx-remote/rsc'
interface BlogPostProps {
post: Post
}
export default function BlogPost({ post }: BlogPostProps) {
return (
<article className="w-full bg-gray-100 p-4 rounded-md">
{/* 제목/날짜 */}
<header className="mb-8">
<h1 className="text-3xl font-bold mb-2">{post.title}</h1>
<time className="text-gray-500">{post.date}</time>
<p className="mt-3 text-lg text-gray-600 leading-relaxed max-w-prose">
{post.description}
</p>
</header>
{/* 본문 전체 레이아웃 (flex + markdown-body) */}
<div className="bg-gray-100">
<div className="!bg-gray-100 p-6 rounded-md">
{/* markdown 본문 */}
<div className="markdown-body !bg-gray-100">
<MDXRemote
source={post.content}
/>
</div>
</div>
</div>
</article>
);
}---
category: 'temp'
title: 'temp'
date: '2025-12-23'
---mdx 포스트 디렉토리 구조
content
├──posts // mdx 파일 담아두는 폴더
├──category0
├──category1
└──category2
| └──index.mdx // category=category2, slug=index
└──index.mdx // 카테고리 없는 특수 페이지정적 사이트 배포시에 빌드타임에 페이지를 미리 컴파일해 제공할 수 있는 generateStaticParams를 사용합니다
// `app/[category]/[slug]/page.tsx`
import { getPostData, getAllPostsData } from '@/lib/posts'
import BlogPost from '@/components/BlogPost';
import { notFound } from 'next/navigation';
export async function generateStaticParams() {
const posts = getAllPostsData();
return posts.map((post) => ({
category: post.category,
slug: post.slug,
}));
}
export default async function Post({ params }: { params: Promise<{ category: string, slug: string }> }) {
const { category, slug } = await params
const postData = getPostData(category, slug);
if(postData === null){
return notFound();
}
return (
<div className="grid grid-cols-[1fr_1000px_1fr] gap-8 w-full">
<div>
</div>
{/* 중앙 콘텐츠 */}
<div className="w-full mx-auto Markdown-body">
<BlogPost post={postData}/>
</div>
<div>
</div>
</div>
)
}// /components/Navigation.tsx
"use client";
import Link from "next/link";
import { useState } from "react";
export default function Navi({ categories }: { categories: string[] }) {
const [open, setOpen] = useState(false);
return (
<nav className="relative w-full h-16 bg-transparent grid grid-cols-[1fr_1000px_1fr]">
<div></div>
{/* 가운데 네비 전체 */}
<div className="flex w-full items-center justify-between">
<div className="flex items-center justify-start px-6 gap-6">
<Link href="/" className="flex text-xl items-center font-bold hover:opacity-70 transition">
<img
src="https://avatars.githubusercontent.com/u/161662653?v=4"
alt="avatar"
className="w-12 h-12 rounded-full"
/>
<p>Main</p>
</Link>
</div>
<div className="flex items-center justify-end px-6 gap-2">
<Link
href="/about"
className="px-3 py-2 hover:bg-gray-200 rounded transition"
>
소개
</Link>
{/* 문서 드롭다운 */}
<div
className="relative"
onPointerEnter={() => setOpen(true)}
onPointerLeave={() => setOpen(false)}
>
<Link
href={`/${encodeURIComponent(categories[0])}`}
className="px-3 py-2 hover:bg-gray-200 rounded transition"
>
문서
</Link>
{open && (
<div
className="absolute mt-0 left-0 min-w-[12rem] bg-white shadow-lg rounded-md border p-2 z-50"
>
{categories.map((cat) => (
<Link
key={cat}
href={`/${encodeURIComponent(cat)}`}
className="block px-3 py-2 hover:bg-gray-100 w-full rounded"
>
{cat}
</Link>
))}
</div>
)}
</div>
</div>
</div>
<div></div>
</nav>
);
}페이지 하단에 표시할 사이트 정보 표시 컴포넌트입니다. 크게 중요한 요소는 아니라서 간략하게 만들어줍니다.
// /components/Footer.tsx
export default function Footer() {
return (
<footer className="w-full border-t mt-12 py-6 text-sm text-gray-600 bg-gray-50">
<div className="max-w-4xl mx-auto px-4 flex flex-col md:flex-row items-start gap-4">
<div className="flex flex-col items-start gap-1">
<p className="font-semibold">Blog</p>
<p className="text-xs text-gray-500">생각과 기록을 남기는 공간 · 업데이트 불규칙</p>
</div>
<div className="flex sm:flex-row items-start sm:items-center gap-4">
<div>
<p className="font-medium">사이트</p>
<nav className="text-xs text-gray-600">
<a className="hover:underline mr-3" href="/">홈</a>
<a className="hover:underline mr-3" href="/about">소개</a>
{/* 일지 링크 제거 */}
</nav>
</div>
<div>
<p className="font-medium">contact</p>
<p className="text-xs text-gray-600">email: null</p>
</div>
</div>
<div className="text-xs text-gray-500">© {new Date().getFullYear()} crusthack. All rights free.</div>
</div>
</footer>
);
}import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import 'github-markdown-css/github-markdown-light.css'
import Navi from "@/components/Navigation";
import Footer from "@/components/Footer";
import { getAllPostsData } from "@/lib/posts";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const postsData = getAllPostsData();
const categories = Array.from(
new Set(postsData.map(post => post.category))
)
.filter((category): category is string => Boolean(category))
.sort((a, b) => {
const latestA = Math.max(
...postsData
.filter(p => p.category === a)
.map(p => new Date(p.date).getTime())
)
const latestB = Math.max(
...postsData
.filter(p => p.category === b)
.map(p => new Date(p.date).getTime())
)
return latestB - latestA // 최신 글 있는 카테고리가 앞으로
})
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<Navi categories={categories} />
{children}
<Footer />
</body>
</html>
);
}
blog
├──app // 앱 라우팅 폴더
│ ├──[category]
| | ├──page.tsx // 카테고리 라우팅 폴더, 해당 카테고리의 최신 글로 리다이렉션
| | └──[slug]
| | └──page.tsx // /[category]/[slug] 일반 블로그 포스트
| ├──about
| | └──page.tsx // /about 소개 페이지
| └──page.tsx // / index 메인 페이지
|
|──content //
└── posts // mdx 파일 저장소
├──index.mdx // 메인 페이지 category=''
├──about.mdx // 소개 페이지
├──category0 // 카테고리 폴더
├──category1
└──category2
└──index.mdx // category=category2, slug=index
.../content/posts/index.mdx 사용/content/posts/about.mdx/content/posts/[category]/[slug].mdx를 서비스합니다// /app/page.tsx
// /app/page.tsx
import { getPostData } from '@/lib/posts'
import BlogPost from '@/components/BlogPost'
import { notFound } from 'next/navigation';
// MDX plugins are provided via shared options
export default async function Post() {
const postData = getPostData("", 'index')
if (postData === null) {
return notFound();
}
return (
<div className="grid grid-cols-[1fr_1000px_1fr] gap-8 w-full">
{/* 왼쪽 사이드 컨텐츠 */}
<div className="flex justify-end">
</div>
{/* 가운데 컨텐츠 */}
<div className="w-full mx-auto">
<BlogPost post={postData}/>
</div>
{/* 오른쪽 사이드 컨텐츠 */}
<div className="flex justify-start">
</div>
</div>
);
}// /about/page.tsx
import BlogPost from "@/components/BlogPost";
import type { Metadata } from "next";
import { getPostData } from "@/lib/posts";
export async function generateMetadata(): Promise<Metadata> {
const post = getPostData("", 'about')
return {
title: post?.title ?? "소개",
description: post?.description ?? "소개 페이지",
};
}
export default async function AboutPage() {
const postData = getPostData("", 'about')
if (postData === null) {
return <div>존재하지 않는 포스트입니다.</div>;
}
return (
<main className="grid grid-cols-[1fr_1000px_1fr] gap-8 w-full">
<div className="flex justify-end">
</div>
<div className="w-full mx-auto">
<BlogPost post={postData}/>
</div>
<div className="flex justify-start">
</div>
</main>
);
}해당 카테고리 포스트들의 index 페이지 먼저, index 페이지가 없다면 최신 글로 리다이렉션합니다.
// /app/[category]/page.tsx
import { getPostData, getAllPostsData } from "@/lib/posts";
import { notFound, redirect } from "next/navigation";
export async function generateStaticParams() {
const posts = getAllPostsData();
const uniqueCategories = Array.from(new Set(posts.map((post) => post.category)));
return uniqueCategories.map((category) => ({ category }));
}
export default async function Post({
params,
}: {
params: Promise<{ category: string }>;
}) {
const { category } = await params;
// 디코드: URL 인코딩(%20, + 등)을 공백으로 변환
const decodedCategory = typeof category === "string" ?
decodeURIComponent(category).replace(/\+/g, " ").trim() : category;
// 카테고리 index.mdx가 있으면 우선 이동
const indexPost = getPostData(decodedCategory, "index");
if (indexPost) {
redirect(`/${encodeURIComponent(decodedCategory)}/index`);
}
// 모든 포스트 불러오기, 날짜 내림차순 정렬
const posts = getAllPostsData();
posts.sort((a, b) => (a.date < b.date ? 1 : -1));
// 해당 카테고리 포스트만 필터 (이미 날짜 내림차순 상태 유지)
const filtered = posts.filter((post) => post.category === decodedCategory);
if (filtered.length === 0) {
return notFound();
}
// 가장 최근 글 slug (날짜 내림차순이므로 첫 번째)
const firstSlug = filtered[0].slug;
// 자동 이동 (경로 안전 인코딩)
redirect(`/${encodeURIComponent(decodedCategory)}/${firstSlug}`);
}// /lib/config.ts
// 환경 설정 파일
export interface SpecialCategoryConfig {
category: string; // category 이름 (posts.category와 동일)
label: string; // 네비에 보일 이름
}
export const specialCategories: SpecialCategoryConfig[] = [
{
category: "Project",
label: "프로젝트",
},
{
category: "Journal",
label: "일지",
},
]; // /components/Navigation.tsx
"use client";
import { Post } from "@/lib/posts";
import { specialCategories } from "@/lib/config";
import Link from "next/link";
import { useState } from "react";
interface NaviProps {
posts: Omit<Post, "content">[];
}
export default function Navi({ posts }: NaviProps) {
const [openMenu, setOpenMenu] = useState<Record<string, boolean>>({});
const open = (key: string) =>
setOpenMenu(prev => ({ ...prev, [key]: true }));
const close = (key: string) =>
setOpenMenu(prev => ({ ...prev, [key]: false }));
const specialKeys = specialCategories.map(sc => sc.category);
const categories = Array.from(
new Set(posts.map(post => post.category))
)
.filter(
(category): category is string =>
Boolean(category) && !specialKeys.includes(category)
)
.sort((a, b) => {
const latestA = Math.max(
...posts
.filter(p => p.category === a)
.map(p => new Date(p.date).getTime())
);
const latestB = Math.max(
...posts
.filter(p => p.category === b)
.map(p => new Date(p.date).getTime())
);
return latestB - latestA;
});
const firstCategory = categories[0] ?? "";
const postsBySpecial = new Map(
specialCategories.map(sc => [
sc.category,
posts.filter(p => p.category === sc.category),
])
);
return (
<nav className="relative w-full h-16 bg-transparent grid grid-cols-[1fr_1000px_1fr]">
<div />
{/* 가운데 네비 */}
<div className="flex w-full items-center justify-between">
{/* 좌측 */}
<div className="flex items-center justify-start px-6 gap-6">
<Link
href="/"
className="flex text-xl items-center font-bold hover:opacity-70 transition"
>
<img
src="https://avatars.githubusercontent.com/u/161662653?v=4"
alt="avatar"
className="w-12 h-12 rounded-full"
/>
<p>Main</p>
</Link>
</div>
{/* 우측 */}
<div className="flex items-center justify-end px-6 gap-2">
<Link
href="/about"
className="px-3 py-2 hover:bg-gray-200 rounded transition"
>
소개
</Link>
{/* 문서 드롭다운 */}
<div
className="relative"
onPointerEnter={() => open("docs")}
onPointerLeave={() => close("docs")}
>
<Link
href={`/${encodeURIComponent(firstCategory)}`}
className="px-3 py-2 hover:bg-gray-200 rounded transition"
>
문서
</Link>
{openMenu["docs"] && (
<div className="absolute left-0 min-w-[12rem] bg-white shadow-lg rounded-md border p-2 z-50">
{categories.map(cat => (
<Link
key={cat}
href={`/${encodeURIComponent(cat)}`}
className="block px-3 py-2 hover:bg-gray-100 rounded"
>
{cat}
</Link>
))}
</div>
)}
</div>
{/* 스페셜 카테고리 (config 기반) */}
{specialCategories.map(sc => {
const items = postsBySpecial.get(sc.category) ?? [];
return (
<div
key={sc.category}
className="relative"
onPointerEnter={() => open(sc.category)}
onPointerLeave={() => close(sc.category)}
>
<Link
href={`/${sc.category}`}
className="px-3 py-2 hover:bg-gray-200 rounded transition"
>
{sc.label}
</Link>
{openMenu[sc.category] && (
<div className="absolute left-0 min-w-[12rem] bg-white shadow-lg rounded-md border p-2 z-50">
{items.map(post => (
<Link
key={post.slug}
href={`/${encodeURIComponent(post.category)}/${encodeURIComponent(post.slug)}`}
className="block px-3 py-2 hover:bg-gray-100 rounded"
>
{post.slug}
</Link>
))}
</div>
)}
</div>
);
})}
</div>
</div>
<div />
</nav>
);
}import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import 'github-markdown-css/github-markdown-light.css'
import Navi from "@/components/Navigation";
import Footer from "@/components/Footer";
import { getAllPostsData } from "@/lib/posts";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const postsData = getAllPostsData();
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<Navi posts={postsData} />
{children}
<Footer />
</body>
</html>
);
}
npm install remark-gfm remark-math remark-toc rehype-slug rehype-katex rehype-pretty-code shiki// /components/BlogPost.tsx
'use client'
import type { Post } from '@/lib/posts';
import { MDXRemote } from 'next-mdx-remote/rsc'
import remarkGfm from "remark-gfm";
import rehypeKatex from "rehype-katex";
import rehypeSlug from "rehype-slug";
import remarkToc from 'remark-toc';
import rehypePrettyCode from "rehype-pretty-code";
import remarkMath from 'remark-math';
const prettyOptions = {
theme: "github-dark",
keepBackground: true,
};
interface BlogPostProps {
post: Post
}
export default function BlogPost({ post }: BlogPostProps) {
return (
<article className="w-full bg-gray-100 p-4 rounded-md">
{/* 제목/날짜 */}
<header className="mb-8">
<h1 className="text-3xl font-bold mb-2">{post.title}</h1>
<time className="text-gray-500">{post.date}</time>
<p className="mt-3 text-lg text-gray-600 leading-relaxed max-w-prose">
{post.description}
</p>
</header>
{/* 본문 전체 레이아웃 (flex + markdown-body) */}
<div className="bg-gray-100">
<div className="!bg-gray-100 p-6 rounded-md">
{/* markdown 본문 */}
<div className="markdown-body !bg-gray-100">
<MDXRemote
source={post.content}
options={{
mdxOptions: {
remarkPlugins: [remarkGfm, remarkToc],
rehypePlugins: [[rehypePrettyCode, prettyOptions], rehypeSlug, rehypeKatex],
},
}}
/>
</div>
</div>
</div>
</article>
);
}remarkPlugins: mdx에 문법을 추가합니다.
remarkGfm: GitHub Flavored Markdown 문법을 추가합니다. 테이블문법(| |), 체크박스([x]), 취소선(~~~text~~~)...remarkToc: mdx에 특정 키워드를 작성하면(## Table of Contents) 해당 위치에 toc, 목차를 생성해줍니다. rehype-slug와 같이 사용됩니다.(따로 toc 만들거라 프로젝트에서 안 쓰임)rehypePlugins: html에 서식을 추가합니다.
rehypePrettyCode: 코드블럭을 꾸며줍니다 (```로 감싸진 코드블럭) 공식문서rehypeSlug: h태그(#)에 자동으로 id를 붙여줍니다. 이를 toc 만드는 데에 사용합니다
rehypeKatex: 수식을 html로 렌더링해줍니다. remarkmath와 같이 쓰입니다// /components/CodeBlock.tsx
"use client";
import { DetailedHTMLProps, HTMLAttributes, useRef, useState } from "react";
const IconCopy = () => (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
);
const IconCheck = () => (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
);
const CodeBlock = ({
className = "",
children,
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLPreElement>, HTMLPreElement>) => {
const [isCopied, setIsCopied] = useState(false);
const preRef = useRef<HTMLPreElement>(null);
const handleCopy = async () => {
const code = preRef.current?.textContent;
if (!code) return;
await navigator.clipboard.writeText(code);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 1200);
};
return (
<div className="relative">
<button
onClick={handleCopy}
aria-label="Copy code"
className="
absolute right-3 top-3
flex items-center gap-1
rounded-md bg-black/60
px-2 py-1 text-xs text-white
hover:bg-black/80 transition
"
>
<span className="flex items-center">{isCopied ? <IconCheck /> : <IconCopy />}</span>
{isCopied ? "Copied!" : "Copy"}
</button>
<pre ref={preRef} {...props} className={className}>
{children}
</pre>
</div>
);
};
export default CodeBlock;// /components/BlogPost.tsx
<MDXRemote
source={post.content}
components={{
pre: (props) => <CodeBlock {...props} />,
}}
options={{
mdxOptions: {
remarkPlugins: [remarkGfm, remarkToc, remarkMath],
rehypePlugins: [[rehypePrettyCode, prettyOptions], rehypeSlug, rehypeKatex],
},
}}
/>동작 이해
printf("hello, world!!");\```c
printf("hello, world!!");
\```
=> 컴파일
<figure data-rehype-pretty-code-figure="">
<pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="c" data-theme="github-dark">
<code data-language="c" data-theme="github-dark" style="display:grid">
<span data-line="">
<span style="color:#B392F0">printf</span>
<span style="color:#E1E4E8">(</span>
<span style="color:#9ECBFF">"hello, world!!"</span>
<span style="color:#E1E4E8">);</span>
</span>
</code>
</pre>
</figure>
=> 오버라이드
<figure data-rehype-pretty-code-figure="">
<div class="relative">
<button aria-label="Copy code" class=" absolute right-3 top-3 flex items-center gap-1 rounded-md bg-black/60 px-2 py-1 text-xs text-white hover:bg-black/80 transition ">
// ...
</button>
<pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="c" data-theme="github-dark" class="">
// ...
</pre>
</div>
</figure>
<h1 id="nextjs-mdx-블로그란">Nextjs mdx 블로그란?</h1>동적 헤딩 규칙
활성화된(보고있는) 태그가 보여야 하고, 해당 태그는 파란색으로 강조됨.활성화된 태그는 현재 보고 있는 창의 맨 위의 50픽셀 아래로부터 위로 올라갔을 때 제일 밑에 있는 태그임)활성화된 태그의 자식 태그들이 보여야 함활성화된 태그의 형제 태그들이 보여야 함활성화된 태그의 부모 태그의 형제들이 보여야 함활성화된 태그의 연쇄적인 부모 태그들이 보여야 함// /lib/parseToc.ts
import { slug } from "github-slugger";
export interface TocItem {
level: number;
text: string;
id: string;
parentId: string | null;
topLevelId: string | null;
}
export function getTocFromMarkdown(content: string): TocItem[] {
const toc: TocItem[] = [];
const usedIds = new Map<string, number>(); // 중복 slug 카운트 저장
const headingRegex = /^(#{1,6})\s+(.*)$/gm;
let match;
const stack: TocItem[] = [];
while ((match = headingRegex.exec(content)) !== null) {
const level = match[1].length;
const text = match[2].trim();
let baseId = slug(text);
let id = baseId;
// 중복 검사 후 직접 -1, -2, -3 부여
if (usedIds.has(baseId)) {
const count = usedIds.get(baseId)! + 1;
usedIds.set(baseId, count);
id = `${baseId}-${count}`;
} else {
usedIds.set(baseId, 0); // 첫 번째 등장
}
const item: TocItem = {
level,
text,
id,
parentId: null,
topLevelId: null,
};
// 스택 정리
while (stack.length > 0 && stack[stack.length - 1].level >= level) {
stack.pop();
}
if (stack.length > 0) {
item.parentId = stack[stack.length - 1].id;
item.topLevelId = stack[0].id;
} else {
item.topLevelId = item.id;
}
stack.push(item);
toc.push(item);
}
return toc;
}// /lib/dynamicHeading.ts
"use client";
import { useState, useEffect } from "react";
export function useDynamicHeading() {
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
useEffect(() => {
const getHeadings = () =>
Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6")) as HTMLElement[];
const handleScroll = () => {
const headings = getHeadings();
headings.sort((a, b) => a.offsetTop - b.offsetTop);
const scrollTop = window.scrollY + 50;
let current: string | null = null;
for (const el of headings) {
if (!el.id) continue;
const top = el.offsetTop;
if (top <= scrollTop) current = el.id;
}
if (!current && headings.length > 0) {
current = headings.find(h => h.id)?.id ?? null;
}
setActiveHeadingId(current);
};
window.addEventListener("scroll", handleScroll);
handleScroll();
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, []);
return activeHeadingId;
}"use client";
import { TocItem } from "@/lib/parseToc";
import { useDynamicHeading } from "@/lib/dynamicHeading";
export default function TOC({ toc }: { toc: TocItem[] }) {
const activeId = useDynamicHeading();
const activeItem = toc.find(t => t.id === activeId) || null;
const parentItem = activeItem?.parentId
? toc.find(t => t.id === activeItem.parentId) || null
: null;
const ancestorIds = new Set<string>();
let cur = activeItem;
while (cur?.parentId) {
ancestorIds.add(cur.parentId);
cur = toc.find(t => t.id === cur!.parentId) || null;
}
return (
<aside className="sticky top-20 h-fit max-h-[80vh] overflow-auto">
<h3 className="font-bold mb-4">목차</h3>
<ul className="space-y-2">
{toc.map((item) => {
const isTopLevel = item.level === 1;
const isActive = item.id === activeId;
const isAncestor = ancestorIds.has(item.id);
const isChildOfActive = item.parentId === activeId;
const isSiblingOfActive =
activeItem?.parentId &&
item.parentId === activeItem.parentId;
const isSiblingOfParent =
parentItem &&
item.parentId === parentItem.parentId &&
item.level === parentItem.level;
const shouldShow =
isTopLevel || // rule 1 모든 h1 태그는 상시 보여야 함
isActive || // rule 2 현재 보고 있는 태그
isChildOfActive || // rule 3 활성화된 태그의 자식
isSiblingOfActive || // rule 4 활성화된 태그의 형제
isSiblingOfParent || // rule 5 활성화된 태그의 부모의 형제
isAncestor; // rule 6 활성화된 태그의 연쇄적인 조상
if (!shouldShow) return null;
return (
<li
key={item.id}
style={{ paddingLeft: (item.level - 1) * 16 }}
>
<a
href={`#${item.id}`}
className={`text-sm hover:underline ${isActive ? "font-bold text-blue-500" : "text-gray-700"
}`}
>
{item.text}
</a>
</li>
);
})}
</ul>
</aside>
);
}// `app/[category]/[slug]/page.tsx`
import { getPostData, getAllPostsData } from '@/lib/posts'
import BlogPost from '@/components/BlogPost';
import { notFound } from 'next/navigation';
import { getTocFromMarkdown } from '@/lib/parseToc';
import TOC from '@/components/TOC';
export async function generateStaticParams() {
const posts = getAllPostsData();
return posts.map((post) => ({
category: post.category,
slug: post.slug,
}));
}
export default async function Post({ params }: { params: Promise<{ category: string, slug: string }> }) {
const { category, slug } = await params
const postData = getPostData(category, slug);
if (postData === null) {
return notFound();
}
const toc = getTocFromMarkdown(postData.content);
return (
<div className="grid grid-cols-[1fr_1000px_1fr] gap-8 w-full">
<div>
</div>
{/* 중앙 콘텐츠 */}
<div className="w-full mx-auto Markdown-body">
<BlogPost post={postData} />
</div>
<div className="flex justify-start">
<TOC toc={toc} />
</div>
</div>
)
}// /components/CategorySidebar.tsx
import Link from "next/link";
import { getPostsByCategory } from "@/lib/posts";
export default function CategorySidebar({ currentCategory }: { currentCategory: string }) {
const posts = getPostsByCategory(currentCategory);
return (
<aside className="sticky top-20 space-y-2 mt-2">
<h2 className="font-bold text-xl mb-3 capitalize">{currentCategory}</h2>
<ul className="space-y-1">
{posts.map(post => (
<li key={post.slug}>
<Link
href={`/${encodeURIComponent(currentCategory)}/${encodeURIComponent(post.slug)}`}
className="block hover:text-blue-600"
>
{post.title ?? post.slug}
</Link>
</li>
))}
</ul>
</aside>
);
}// /app/[category]/[slug]/page.tsx`
import { getPostData, getAllPostsData } from '@/lib/posts'
import BlogPost from '@/components/BlogPost';
import { notFound } from 'next/navigation';
import { getTocFromMarkdown } from '@/lib/parseToc';
import TOC from '@/components/TOC';
import CategorySidebar from '@/components/CategorySidebar';
export async function generateStaticParams() {
const posts = getAllPostsData();
return posts.map((post) => ({
category: post.category,
slug: post.slug,
}));
}
export default async function Post({ params }: { params: Promise<{ category: string, slug: string }> }) {
const { category, slug } = await params
const postData = getPostData(category, slug);
if (postData === null) {
return notFound();
}
const toc = getTocFromMarkdown(postData.content);
return (
<div className="grid grid-cols-[1fr_1000px_1fr] gap-8 w-full">
<div className="flex justify-end">
<CategorySidebar currentCategory={category} />
</div>
{/* 중앙 콘텐츠 */}
<div className="w-full mx-auto Markdown-body">
<BlogPost post={postData} />
</div>
<div className="flex justify-start">
<TOC toc={toc} />
</div>
</div>
)
}// /components/LeftSidebar.tsx
import Link from "next/link";
import { getAllPostsData } from "@/lib/posts";
export default function LeftSidebar() {
const allPosts = getAllPostsData();
const recentPosts = allPosts.slice(0, 3);
const categories: string[] = Array.from(
new Set(allPosts.map((post) => post.category).filter((cat): cat is string => Boolean(cat)))
).sort();
const counts = allPosts.reduce<Record<string, number>>((acc, p) => {
const cat = p.category ?? "";
acc[cat] = (acc[cat] ?? 0) + 1;
return acc;
}, {});
return (
<aside className="sticky top-20 space-y-2 mt-2">
<section>
<h2 className="font-bold text-xl mb-3">최근 글 보기</h2>
<ul className="space-y-1">
{recentPosts.map((post) => (
<li key={`${post.category}-${post.slug}`}>
<Link
href={`/${encodeURIComponent(post.category!)}/${encodeURIComponent(post.slug)}`}
className="block hover:text-blue-600"
>
{post.title ?? post.slug}
</Link>
</li>
))}
</ul>
</section>
<section>
<h2 className="font-bold text-xl mt-5 mb-3">카테고리 목록</h2>
<ul className="space-y-1">
{categories.map((cat) => (
<li key={cat}>
<Link
href={`/${encodeURIComponent(cat)}`}
className="flex items-center justify-between hover:text-blue-600"
>
<span>{cat} ({counts[cat] ?? 0})</span>
</Link>
</li>
))}
</ul>
</section>
</aside>
);
}// /lib/config.ts
// 환경 설정 파일
export interface SpecialCategoryConfig {
category: string; // category 이름 (posts.category와 동일)
label: string; // 네비에 보일 이름
}
export const specialCategories: SpecialCategoryConfig[] = [
{
category: "Project",
label: "프로젝트",
},
{
category: "Journal",
label: "일지",
},
];
// 유용한 사이트
export interface UsefulLink {
name: string;
url: string;
}
export const usefulLinks: UsefulLink[] = [
{ name: "Next.js 학습자료", url: "https://sangkon.com/practice-ts/" },
];// /components/RightSidebar.tsx
import Link from "next/link";
import { getAllPostsData } from "@/lib/posts";
import { usefulLinks } from "@/lib/config";
export default function RightSidebar() {
const allPosts = getAllPostsData();
const recentProjectPosts = allPosts
.filter((p) => p.category === "Project")
.slice(0, 5);
return (
<aside className="sticky top-20 space-y-2 mt-2">
<section>
<h2 className="font-bold text-xl mb-3">최근 프로젝트</h2>
<ul className="space-y-1">
{recentProjectPosts.map((post) => (
<li key={`project-${post.slug}`}>
<Link
href={`/${encodeURIComponent(post.category!)}/${encodeURIComponent(post.slug)}`}
className="block hover:text-blue-600"
>
{post.title ?? post.slug}
</Link>
</li>
))}
{recentProjectPosts.length === 0 && (
<li className="text-sm text-gray-500">프로젝트 글이 없습니다.</li>
)}
</ul>
</section>
<section>
<h2 className="font-bold text-xl mt-5 mb-3">유용한 사이트</h2>
<ul className="space-y-1">
{usefulLinks.map((l) => (
<li key={l.url}>
<a
href={l.url}
target="_blank"
rel="noopener noreferrer"
className="block hover:text-blue-600"
>
{l.name}
</a>
</li>
))}
</ul>
</section>
</aside>
);
}// /app/page.tsx
import { getPostData } from '@/lib/posts'
import BlogPost from '@/components/BlogPost'
import LeftSidebar from '@/components/LeftSidebar';
import RightSidebar from '@/components/RightSidebar';
import { notFound } from 'next/navigation';
// MDX plugins are provided via shared options
export default async function Post() {
const postData = getPostData("", 'index')
if (postData === null) {
return notFound();
}
return (
<div className="grid grid-cols-[1fr_1000px_1fr] gap-8 w-full">
{/* 왼쪽 사이드 컨텐츠 */}
<div className="flex justify-end">
<LeftSidebar />
</div>
{/* 가운데 컨텐츠 */}
<div className="w-full mx-auto">
<BlogPost post={postData}/>
</div>
{/* 오른쪽 사이드 컨텐츠 */}
<div className="flex justify-start">
<RightSidebar />
</div>
</div>
);
}// /app/about/page.tsx
import BlogPost from "@/components/BlogPost";
import type { Metadata } from "next";
import { getPostData } from "@/lib/posts";
import LeftSidebar from "@/components/LeftSidebar";
import RightSidebar from "@/components/RightSidebar";
export async function generateMetadata(): Promise<Metadata> {
const post = getPostData("", 'about')
return {
title: post?.title ?? "소개",
description: post?.description ?? "소개 페이지",
};
}
export default async function AboutPage() {
const postData = getPostData("", 'about')
if (postData === null) {
return <div>존재하지 않는 포스트입니다.</div>;
}
return (
<main className="grid grid-cols-[1fr_1000px_1fr] gap-8 w-full">
<div className="flex justify-end">
<LeftSidebar />
</div>
<div className="w-full mx-auto">
<BlogPost post={postData}/>
</div>
<div className="flex justify-start">
<RightSidebar />
</div>
</main>
);
}---
title: 페이지를 찾을 수 없습니다
date: "2025-12-18"
description: 요청하신 문서를 찾을 수 없어요. 아래 링크를 참고해 주세요.
---
\# 페이지를 찾을 수 없습니다
요청하신 카테고리 또는 글이 존재하지 않아요.
- 홈으로 이동하거나 카테고리에서 다른 글을 찾아보세요.
- 주소가 정확한지 다시 확인해 주세요.
[홈으로 이동](/)
// /app/not-found.tsx
import { getPostData } from "@/lib/posts";
import BlogPost from "@/components/BlogPost";
import { TocItem, getTocFromMarkdown } from "@/lib/parseToc";
import LeftSidebar from "@/components/LeftSidebar";
import RightSidebar from "@/components/RightSidebar";
export default function NotFound() {
let postData = getPostData("", "404");
let toc: TocItem[] = [];
toc = getTocFromMarkdown(postData!.content);
return (
<div className="grid grid-cols-[1fr_1000px_1fr] gap-8 w-full">
<div className="flex justify-end">
<LeftSidebar />
</div>
{/* 중간 콘텐츠 */}
<div className="w-full mx-auto">
<BlogPost post={postData!}/>
</div>
{/* 오른쪽 TOC → 클라이언트 컴포넌트 */}
<div className="flex justify-start">
<RightSidebar />
</div>
</div>
);
}layout.tsx에 추가
export const metadata: Metadata = {
icons:{
icon: "" // 이미지 주소
},
title: "개발블로그",
description: "Generated by create next app",
};/app/[category]/[slug]/page.tsx에 추가
import { Metadata } from 'next';
export async function generateMetadata({
params,
}: {
params: Promise<{ category: string; slug: string }>;
}): Promise<Metadata> {
const { category, slug } = await params;
const decodedCategory =
typeof category === "string"
? decodeURIComponent(category).replace(/\+/g, " ").trim()
: category;
let post = getPostData(decodedCategory, slug);
if (post === null) {
if (!post) {
post = getPostData("", "404");
}
}
return {
title: post!.title,
description: post!.description,
};
} // 외부 이미지 링크
 // public/a.JPG 링크
// /lib/config.ts
// 환경 설정 파일
export const isLocalDev = process.env.NODE_ENV === 'development'
export const repoName = "blog"
export interface SpecialCategoryConfig {
category: string; // category 이름 (posts.category와 동일)
label: string; // 네비에 보일 이름
}
export const specialCategories: SpecialCategoryConfig[] = [
{
category: "Project",
label: "프로젝트",
},
{
category: "Journal",
label: "일지",
},
];
// 유용한 사이트
export interface UsefulLink {
name: string;
url: string;
}
export const usefulLinks: UsefulLink[] = [
{ name: "Next.js 학습자료", url: "https://sangkon.com/practice-ts/" },
];// /components/MdxImage.tsx
import { isLocalDev, repoName } from "@/lib/config";
import Image from "next/image";
import { ImgHTMLAttributes } from "react";
const resolveImagePath = (category: string, slug: string, value: string | undefined): string => {
const v = String(value);
// 웹 이미지 주소는 그대로
if (
v.startsWith("http://") ||
v.startsWith("https://")
) {
return v;
}
const baseurl = isLocalDev ? '' : `/${repoName}`;
return `${baseurl}/images/${encodeURIComponent(category)}/${encodeURIComponent(slug)}/${v}`;
};
interface MdxImageProps extends ImgHTMLAttributes<HTMLImageElement> {
category: string;
slug: string;
}
export const MdxImage = ({ category, slug, ...props }: MdxImageProps) => {
const {
src,
alt,
width,
height,
...rest
} = props;
const resolvedSrc = resolveImagePath(category, slug, src as string);
return (
<span className="relative block w-full aspect-[3/2]">
<Image
unoptimized
src={resolvedSrc}
alt={alt ?? ""}
fill
className="object-contain"
{...rest}
/>
</span>
);
};// components/BlogPost.tsx
'use client'
import type { Post } from '@/lib/posts';
import { MDXRemote } from 'next-mdx-remote/rsc'
import remarkGfm from "remark-gfm";
import rehypeKatex from "rehype-katex";
import rehypeSlug from "rehype-slug";
import remarkToc from 'remark-toc';
import rehypePrettyCode from "rehype-pretty-code";
import remarkMath from 'remark-math';
import CodeBlock from '@/components/CodeBlock';
import { MdxImage } from '@/components/MdxImage';
const prettyOptions = {
theme: "github-dark",
keepBackground: true,
};
interface BlogPostProps {
post: Post
}
export default function BlogPost({ post }: BlogPostProps) {
return (
<article className="w-full bg-gray-100 p-4 rounded-md">
{/* 제목/날짜 */}
<header className="mb-8">
<h1 className="text-3xl font-bold mb-2">{post.title}</h1>
<time className="text-gray-500">{post.date}</time>
<p className="mt-3 text-lg text-gray-600 leading-relaxed max-w-prose">
{post.description}
</p>
</header>
{/* 본문 전체 레이아웃 (flex + markdown-body) */}
<div className="bg-gray-100">
<div className="!bg-gray-100 p-6 rounded-md">
{/* markdown 본문 */}
<div className="markdown-body !bg-gray-100">
<MDXRemote
source={post.content}
components={{
pre: (props) => <CodeBlock {...props} />,
img: (props) => <MdxImage category={post.category} slug={post.slug} {...props} />,
}}
options={{
mdxOptions: {
remarkPlugins: [remarkGfm, remarkToc, remarkMath],
rehypePlugins: [[rehypePrettyCode, prettyOptions], rehypeSlug, rehypeKatex],
},
}}
/>
</div>
</div>
</div>
</article>
);
}// components/BlogPost.tsx
'use client'
import type { Post } from '@/lib/posts';
import { MDXRemote } from 'next-mdx-remote/rsc'
import remarkGfm from "remark-gfm";
import rehypeKatex from "rehype-katex";
import rehypeSlug from "rehype-slug";
import remarkToc from 'remark-toc';
import rehypePrettyCode from "rehype-pretty-code";
import remarkMath from 'remark-math';
import CodeBlock from '@/components/CodeBlock';
import { MdxImage } from '@/components/MdxImage';
import { isLocalDev, repoName } from '@/lib/config';
const prettyOptions = {
theme: "github-dark",
keepBackground: true,
};
interface BlogPostProps {
post: Post
}
function resolveLink(href: string): string {
if (!href.startsWith('/')
) {
return href;
}
const baseurl = isLocalDev ? `` : `/${repoName}`;
return `${baseurl}${href}`;
}
export default function BlogPost({ post }: BlogPostProps) {
return (
<article className="w-full bg-gray-100 p-4 rounded-md min-h-screen">
{/* 제목/날짜 */}
<header className="mb-8">
<h1 className="text-3xl font-bold mb-2">{post.title}</h1>
<time className="text-gray-500">{post.date}</time>
<p className="mt-3 text-lg text-gray-600 leading-relaxed max-w-prose">
{post.description}
</p>
</header>
{/* 본문 전체 레이아웃 (flex + markdown-body) */}
<div className="bg-gray-100">
<div className="!bg-gray-100 p-6 rounded-md">
{/* markdown 본문 */}
<div className="markdown-body !bg-gray-100">
<MDXRemote
source={post.content}
components={{
pre: (props) => <CodeBlock {...props} />,
img: (props) => <MdxImage category={post.category} slug={post.slug} {...props} />,
ul: (props) => <ul className="list-disc space-y-2 ml-6 my-4" {...props} />,
ol: (props) => <ol className="list-decimal space-y-2 ml-6 my-4" {...props} />,
a: (props) => {
const href = props.href || '';
const url = resolveLink(href);
return <a {...props} href={url} />;
}
}}
options={{
mdxOptions: {
remarkPlugins: [remarkGfm, remarkToc, remarkMath],
rehypePlugins: [[rehypePrettyCode, prettyOptions], rehypeSlug, rehypeKatex],
},
}}
/>
</div>
</div>
</div>
</article>
);
}