From b14cb16e4b848f05be87c2446a63d526d3c810f3 Mon Sep 17 00:00:00 2001 From: Jongchan Date: Thu, 17 Jul 2025 15:16:35 +0900 Subject: [PATCH 01/14] =?UTF-8?q?feat:=20posts=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=ED=8C=8C=EC=9D=BC=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EC=BA=90=EC=8B=B1=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getPostBySlug 함수를 캐시된 allPosts에서 조회하도록 변경 - findPostPathBySlug 함수 제거하여 중복 파일 스캔 방지 - getAllMdxFiles, getPostByFilePath 함수 추가로 재귀적 MDX 파일 처리 개선 - SSG 빌드 시 파일을 한 번만 읽도록 최적화 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/entities/posts/index.ts | 63 ++++++++++++++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 4 deletions(-) diff --git a/src/entities/posts/index.ts b/src/entities/posts/index.ts index b63fe6a..fa76243 100644 --- a/src/entities/posts/index.ts +++ b/src/entities/posts/index.ts @@ -1,4 +1,4 @@ -import { readdir, readFile } from 'node:fs/promises' +import { readdir, readFile, stat } from 'node:fs/promises' import { join } from 'node:path' import matter from 'gray-matter' @@ -21,14 +21,62 @@ export async function getPostByFileName(fileName: string) { const fullPath = join(postsDirectory, fileName) const content = await readFile(fullPath, 'utf-8') const parsed = matter(content) as PostGrayMatter - return parsePostData(parsed, fileName) + return parsePostData(parsed, fileName, fullPath) +} + +/** + * 모든 MDX 파일을 재귀적으로 찾습니다 (서브디렉토리 포함). + */ +export async function getAllMdxFiles(dir: string = postsDirectory): Promise< + Array<{ + filePath: string + fileName: string + }> +> { + const files: Array<{ + filePath: string + fileName: string + }> = [] + const entries = await readdir(dir) + + for (const entry of entries) { + const fullPath = join(dir, entry) + const stats = await stat(fullPath) + + if (stats.isDirectory()) { + // 서브디렉토리가 있으면 재귀적으로 스캔 + const subFiles = await getAllMdxFiles(fullPath) + files.push(...subFiles) + } else if (entry.endsWith('.mdx')) { + files.push({ + filePath: fullPath, + fileName: entry, + }) + } + } + + return files +} + +/** + * 경로와 파일명으로 포스트를 가져옵니다. + */ +export async function getPostByFilePath( + filePath: string, + fileName: string +): Promise { + const content = await readFile(filePath, 'utf-8') + const parsed = matter(content) as PostGrayMatter + return parsePostData(parsed, fileName, filePath) } const allPosts = await getAllPosts() export async function getAllPosts() { - const fileNames = await getPostFileNames() - const postPromises = fileNames.map((fileName) => getPostByFileName(fileName)) + const mdxFiles = await getAllMdxFiles() + const postPromises = mdxFiles.map(({ filePath, fileName }) => + getPostByFilePath(filePath, fileName) + ) const posts = await Promise.all(postPromises) return sortPostsByDate(posts) } @@ -41,4 +89,11 @@ export async function getPostNavigation(currentSlug: string) { return findAdjacentPosts(allPosts, currentSlug) } +/** + * 슬러그로 포스트를 가져옵니다. + */ +export function getPostBySlug(slug: string): Post | null { + return allPosts.find((post) => post.slug === slug) || null +} + export default allPosts From 016f81aa2a8e6af95448505c88b66f154bf68b55 Mon Sep 17 00:00:00 2001 From: Jongchan Date: Thu, 17 Jul 2025 15:16:48 +0900 Subject: [PATCH 02/14] =?UTF-8?q?feat:=20tags=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EA=B7=B8=EB=9E=98=ED=94=84=20=EB=B0=8F=20=ED=81=B4?= =?UTF-8?q?=EB=9F=AC=EC=8A=A4=ED=84=B0=20=EC=BA=90=EC=8B=B1=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 태그 그래프를 모듈 레벨에서 캐시하여 중복 생성 방지 - 태그 클러스터 계산 결과 캐싱으로 성능 개선 - findRelatedPostsByTagsOptimized 함수 추가로 캐시된 클러스터 활용 - getTagGraph, getTagRelationships, getTagClusters 함수 동기화로 성능 향상 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/entities/tags/index.ts | 28 +++++++++++++++------------- src/entities/tags/logic.ts | 21 +++++++++++++++++++++ 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/src/entities/tags/index.ts b/src/entities/tags/index.ts index 3de22e0..37ad062 100644 --- a/src/entities/tags/index.ts +++ b/src/entities/tags/index.ts @@ -7,6 +7,7 @@ import { createTagGraph, extractTagsFromPosts, findRelatedPostsByTags, + findRelatedPostsByTagsOptimized, findTagByName, } from './logic' import type { Tag, TagCluster, TagRelationship } from './types' @@ -27,27 +28,28 @@ export async function getTagByName(name: string): Promise { return findTagByName(tags, name) } +// 태그 그래프를 모듈 레벨에서 캐시 +const tagGraph = createTagGraph(posts, tags) + /** * 태그 그래프를 생성합니다. */ -export async function getTagGraph() { - return createTagGraph(posts, tags) +export function getTagGraph() { + return tagGraph } /** * 태그 간의 관계를 분석합니다. */ -export async function getTagRelationships(): Promise { - const graph = await getTagGraph() - return analyzeTagRelationships(graph) +export function getTagRelationships(): TagRelationship[] { + return analyzeTagRelationships(tagGraph) } /** * 태그 클러스터를 생성합니다. */ -export async function getTagClusters(): Promise { - const graph = await getTagGraph() - return createTagClusters(graph) +export function getTagClusters(): TagCluster[] { + return createTagClusters(tagGraph) } /** @@ -58,12 +60,12 @@ export async function getTagStats() { return calculateTagStats(tags) } +// 태그 클러스터를 모듈 레벨에서 캐시 +const tagClusters = createTagClusters(tagGraph) + /** * 현재 포스트와 태그 기반으로 관련된 포스트들을 찾습니다. */ -export async function getRelatedPostsByTags( - currentSlug: string, - limit: number = 3 -) { - return findRelatedPostsByTags(posts, currentSlug, limit) +export function getRelatedPostsByTags(currentSlug: string, limit: number = 3) { + return findRelatedPostsByTagsOptimized(posts, currentSlug, tagClusters, limit) } diff --git a/src/entities/tags/logic.ts b/src/entities/tags/logic.ts index 93eda23..e75d7e9 100644 --- a/src/entities/tags/logic.ts +++ b/src/entities/tags/logic.ts @@ -244,6 +244,27 @@ export function findRelatedPostsByTags( const graph = createTagGraph(posts, tags) const clusters = createTagClusters(graph) + return findRelatedPostsByTagsOptimized(posts, currentSlug, clusters, limit) +} + +/** + * 최적화된 관련 포스트 찾기 함수 (미리 계산된 클러스터 사용) + */ +export function findRelatedPostsByTagsOptimized( + posts: Post[], + currentSlug: string, + clusters: TagCluster[], + limit: number = 3 +): Array> { + const currentPost = posts.find((post) => post.slug === currentSlug) + if ( + !currentPost || + !currentPost.data.tags || + currentPost.data.tags.length === 0 + ) { + return [] + } + const currentTags = currentPost.data.tags const relatedPosts: Array<{ post: Post From 3b256ea047e1a8b6e45b8b7ec81508817159c722 Mon Sep 17 00:00:00 2001 From: Jongchan Date: Thu, 17 Jul 2025 15:17:02 +0900 Subject: [PATCH 03/14] =?UTF-8?q?feat:=20posts=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - parsePostData 함수에 카테고리 추론 로직 추가 - extractCategoryFromPath 함수로 파일 경로에서 카테고리 자동 추출 - CategoryId 타입 추가로 타입 안정성 향상 - parsePostData, extractCategoryFromPath 관련 테스트 케이스 추가 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/entities/posts/logic.ts | 38 +++++++++- src/entities/posts/posts.test.ts | 117 +++++++++++++++++++++++++++++++ src/entities/posts/types.ts | 4 ++ 3 files changed, 157 insertions(+), 2 deletions(-) diff --git a/src/entities/posts/logic.ts b/src/entities/posts/logic.ts index b1fcb5f..f4a6027 100644 --- a/src/entities/posts/logic.ts +++ b/src/entities/posts/logic.ts @@ -12,15 +12,49 @@ export function filterMdxFiles(files: string[]): string[] { /** * 파싱된 데이터를 Post 객체로 변환합니다. */ -export function parsePostData(parsed: PostGrayMatter, fileName: string): Post { +export function parsePostData( + parsed: PostGrayMatter, + fileName: string, + filePath?: string +): Post { + // 디렉토리 경로에서 카테고리 추론 + const category = filePath ? extractCategoryFromPath(filePath) : undefined + return { content: parsed.content, - data: parsed.data, + data: { + ...parsed.data, + // frontmatter에 category가 없으면 디렉토리에서 추론 + category: parsed.data.category || category, + }, slug: fileName.replace(/\.mdx$/, ''), author: author, } } +/** + * 파일 경로에서 카테고리를 추론합니다. + */ +export function extractCategoryFromPath(filePath: string): string | undefined { + const pathSegments = filePath.split('/') + const contentsIndex = pathSegments.findIndex( + (segment) => segment === 'contents' + ) + + if (contentsIndex === -1 || contentsIndex >= pathSegments.length - 1) { + return undefined + } + + const categorySegment = pathSegments[contentsIndex + 1] + + // 카테고리가 유효한지 확인 (dev, life만 허용) + if (categorySegment === 'dev' || categorySegment === 'life') { + return categorySegment + } + + return undefined +} + /** * 포스트 목록을 날짜순으로 정렬합니다 (최신순). */ diff --git a/src/entities/posts/posts.test.ts b/src/entities/posts/posts.test.ts index 7f547b4..6ff5577 100644 --- a/src/entities/posts/posts.test.ts +++ b/src/entities/posts/posts.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { + extractCategoryFromPath, filterMdxFiles, filterPostsByTag, findAdjacentPosts, @@ -326,6 +327,122 @@ describe('Posts Logic - Business Logic Tests', () => { }) }) + describe('extractCategoryFromPath', () => { + it('should extract dev category from path', () => { + const path = '/home/user/project/src/contents/dev/post.mdx' + const result = extractCategoryFromPath(path) + expect(result).toBe('dev') + }) + + it('should extract life category from path', () => { + const path = '/home/user/project/src/contents/life/post.mdx' + const result = extractCategoryFromPath(path) + expect(result).toBe('life') + }) + + it('should return undefined for root contents directory', () => { + const path = '/home/user/project/src/contents/post.mdx' + const result = extractCategoryFromPath(path) + expect(result).toBeUndefined() + }) + + it('should return undefined for invalid category', () => { + const path = '/home/user/project/src/contents/invalid/post.mdx' + const result = extractCategoryFromPath(path) + expect(result).toBeUndefined() + }) + + it('should return undefined for path without contents directory', () => { + const path = '/home/user/project/src/other/dev/post.mdx' + const result = extractCategoryFromPath(path) + expect(result).toBeUndefined() + }) + }) + + describe('parsePostData', () => { + it('should parse post data with category from frontmatter', () => { + const mockParsed: PostGrayMatter = { + content: 'Post content', + data: { + title: 'Test Post', + date: '2025-01-01', + tags: [ + 'test', + ], + description: 'Test description', + category: 'dev', + }, + excerpt: '', + orig: '', + language: '', + matter: '', + stringify: () => '', + } + + const result = parsePostData(mockParsed, 'test.mdx') + + expect(result.data.category).toBe('dev') + expect(result.slug).toBe('test') + }) + + it('should infer category from file path when not in frontmatter', () => { + const mockParsed: PostGrayMatter = { + content: 'Post content', + data: { + title: 'Test Post', + date: '2025-01-01', + tags: [ + 'test', + ], + description: 'Test description', + }, + excerpt: '', + orig: '', + language: '', + matter: '', + stringify: () => '', + } + + const result = parsePostData( + mockParsed, + 'test.mdx', + '/project/src/contents/life/test.mdx' + ) + + expect(result.data.category).toBe('life') + expect(result.slug).toBe('test') + }) + + it('should prefer frontmatter category over path category', () => { + const mockParsed: PostGrayMatter = { + content: 'Post content', + data: { + title: 'Test Post', + date: '2025-01-01', + tags: [ + 'test', + ], + description: 'Test description', + category: 'dev', + }, + excerpt: '', + orig: '', + language: '', + matter: '', + stringify: () => '', + } + + const result = parsePostData( + mockParsed, + 'test.mdx', + '/project/src/contents/life/test.mdx' + ) + + expect(result.data.category).toBe('dev') + expect(result.slug).toBe('test') + }) + }) + describe('findAdjacentPosts', () => { const posts: Post[] = [ { diff --git a/src/entities/posts/types.ts b/src/entities/posts/types.ts index eae4f73..0ba8837 100644 --- a/src/entities/posts/types.ts +++ b/src/entities/posts/types.ts @@ -1,10 +1,13 @@ import type { GrayMatterFile } from 'gray-matter' +import type { CategoryId } from '@/entities/categories/types' + export type PostFrontMatter = { title: string date: string tags: Array description: string + category?: CategoryId } export type Post = { @@ -41,6 +44,7 @@ export type PostMetadata = { publishedTime: string authors: string[] tags: string[] + section?: string } twitter: { card: 'summary_large_image' From e41ae43f9d5897f74db5775130205f13121a60f3 Mon Sep 17 00:00:00 2001 From: Jongchan Date: Thu, 17 Jul 2025 15:19:10 +0900 Subject: [PATCH 04/14] =?UTF-8?q?docs:=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EA=B5=AC=EC=A1=B0=20=EB=B0=8F=20=EC=95=84=ED=82=A4?= =?UTF-8?q?=ED=85=8D=EC=B2=98=20=EB=AC=B8=EC=84=9C=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 프로젝트 구조에 graphology 라이브러리 추가 - 디렉토리 구조를 카테고리 기반 라우팅으로 업데이트 - 새로운 엔티티(categories) 및 컴포넌트 구조 반영 - MDX 컴포넌트 및 테스트 디렉토리 추가 - GEMINI.md 문서 추가 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .kiro/steering/project-structure.md | 1 + CLAUDE.md | 29 ++- GEMINI.md | 365 ++++++++++++++++++++++++++++ 3 files changed, 384 insertions(+), 11 deletions(-) create mode 100644 GEMINI.md diff --git a/.kiro/steering/project-structure.md b/.kiro/steering/project-structure.md index 6012217..3be323c 100644 --- a/.kiro/steering/project-structure.md +++ b/.kiro/steering/project-structure.md @@ -13,6 +13,7 @@ Next.js 15 기반의 정적 블로그 애플리케이션으로, MDX를 사용한 - **코드 품질**: Biome (formatting/linting) - **테스팅**: Vitest - **패키지 매니저**: pnpm +- **그래프**: graphology ## 디렉토리 구조 diff --git a/CLAUDE.md b/CLAUDE.md index 5b472c8..ae9bfad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,17 +22,24 @@ Next.js 15 기반의 정적 블로그 애플리케이션으로, MDX를 사용한 ``` src/ ├── app/ # Next.js App Router 루트 -│ ├── _components/ # 전역 컴포넌트 -│ ├── _fonts/ # 폰트 설정 -│ ├── _lib/ # 유틸리티 함수 -│ ├── posts/[slug]/ # 동적 포스트 페이지 -│ ├── about/ # About 페이지 -│ ├── layout.tsx # 루트 레이아웃 -│ └── globals.css # 전역 스타일 -├── entities/ # 도메인 엔티티 -│ ├── posts/ # 포스트 도메인 로직 -│ └── tags/ # 태그 도메인 로직 -└── contents/ # MDX 블로그 포스트 +│ ├── [category]/ # 카테고리별 포스트 목록 페이지 +│ │ ├── [slug]/ # 개별 포스트 상세 페이지 +│ │ │ └── page.tsx +│ │ ├── _components/ # 카테고리/포스트 관련 컴포넌트 +│ │ └── page.tsx +│ ├── _components/ # 전역 공통 컴포넌트 +│ ├── _fonts/ # 폰트 설정 +│ ├── _lib/ # 공통 유틸리티 함수 +│ ├── about/ # About 페이지 +│ ├── layout.tsx # 루트 레이아웃 +│ └── page.tsx # 메인 페이지 +├── contents/ # MDX 블로그 포스트 원본 파일 +├── entities/ # 도메인 엔티티 (비즈니스 로직) +│ ├── categories/ # 카테고리 도메인 로직 +│ ├── posts/ # 포스트 도메인 로직 +│ └── tags/ # 태그 도메인 로직 +├── mdx-components.tsx # MDX 렌더링 시 사용할 커스텀 컴포넌트 +└── test/ # 테스트 관련 설정 ``` ## 네이밍 컨벤션 diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..6966f8c --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,365 @@ +# GEMINI.md + +This file provides guidance to Gemini when working with code in this repository. + +## 프로젝트 개요 + +Next.js 15 기반의 정적 블로그 애플리케이션으로, MDX를 사용한 콘텐츠 관리와 도메인 주도 설계를 따릅니다. + +## 기술 스택 + +- **프레임워크**: Next.js 15 (App Router) +- **콘텐츠**: MDX with rehype-pretty-code +- **스타일링**: Tailwind CSS + shadcn/ui +- **언어**: TypeScript +- **코드 품질**: Biome (formatting/linting) +- **테스팅**: Vitest +- **패키지 매니저**: pnpm +- **그래프**: graphology + +## 디렉토리 구조 + +``` +src/ +├── app/ # Next.js App Router 루트 +│ ├── [category]/ # 카테고리별 포스트 목록 페이지 +│ │ ├── [slug]/ # 개별 포스트 상세 페이지 +│ │ │ └── page.tsx +│ │ ├── _components/ # 카테고리/포스트 관련 컴포넌트 +│ │ └── page.tsx +│ ├── _components/ # 전역 공통 컴포넌트 +│ ├── _fonts/ # 폰트 설정 +│ ├── _lib/ # 공통 유틸리티 함수 +│ ├── about/ # About 페이지 +│ ├── layout.tsx # 루트 레이아웃 +│ └── page.tsx # 메인 페이지 +├── contents/ # MDX 블로그 포스트 원본 파일 +├── entities/ # 도메인 엔티티 (비즈니스 로직) +│ ├── categories/ # 카테고리 도메인 로직 +│ ├── posts/ # 포스트 도메인 로직 +│ └── tags/ # 태그 도메인 로직 +├── mdx-components.tsx # MDX 렌더링 시 사용할 커스텀 컴포넌트 +└── test/ # 테스트 관련 설정 +``` + +## 네이밍 컨벤션 + +- **라우트가 아닌 폴더**: 언더스코어 접두사 사용 (`_components`, `_hooks`) +- **컴포넌트 스코프**: + - 전역: `src/app/_components/` + - 페이지별: `src/app/[route]/_components/` +- **파일명**: kebab-case 또는 PascalCase (컴포넌트) + +## 도메인 아키텍처 + +### 엔티티 구조 +- `src/entities/posts/`: 포스트 관련 비즈니스 로직 + - `index.ts`: 공개 API + - `types.ts`: 타입 정의 + - `logic.ts`: 비즈니스 로직 + - `*.test.ts`: 테스트 파일 + +### 데이터 흐름 +1. MDX 파일 (`src/contents/`) → gray-matter 파싱 +2. 엔티티 레이어에서 비즈니스 로직 처리 +3. App Router 페이지에서 렌더링 + +## 스타일링 가이드라인 + +### Tailwind CSS +- **컬러 팔레트**: `stone` 계열 사용 (`text-stone-900`, `border-stone-200`) +- **반응형**: 모바일 퍼스트 접근 +- **다크모드**: CSS 변수 기반 테마 지원 + +### shadcn/ui 컴포넌트 +- **설치**: `npx shadcn@latest add ` +- **위치**: `src/app/_components/ui/` +- **스타일**: "new-york" 스타일, stone 베이스 컬러 +- **아이콘**: Lucide React 사용 + +## 콘텐츠 관리 + +### 콘텐츠 위치 +`src/contents/*.mdx` + +### MDX 구조 +```yaml +--- +title: '포스트 제목' +slug: 'post-slug' +date: 2025-01-01 +tags: ['tag1', 'tag2'] +--- + +# 포스트 내용 +``` + +## 작업 진행 원칙 + +- 작업의 단위를 가능하면 작게 설정 +- 지시가 모호하다고 느껴지면 질문 후 진행 + +## 구현 원칙 + +### 점진적 개발 +- 한 번에 하나의 태스크만 집중하여 구현 +- 태스크 완료 후 사용자 검토 대기, 자동으로 다음 태스크 진행하지 않음 +- 각 단계에서 이전 단계의 결과물을 기반으로 구축 + +### 코드 품질 +- TypeScript 엄격 모드 준수, any 타입 사용 금지 +- 컴포넌트는 단일 책임 원칙 적용 +- Props 인터페이스 명시적 정의 +- 적절한 기본값 설정 + +### 테스트 우선 +- 비즈니스 로직 구현 시 단위 테스트 함께 작성 +- AAA 패턴 (Arrange, Act, Assert) 준수 +- 의미있는 테스트명 사용 + +## 구현 패턴 + +### 컴포넌트 설계 +- **단일 책임 원칙**: 하나의 컴포넌트는 하나의 역할만 +- **Props 인터페이스**: 모든 props에 대한 명시적 타입 정의 +- **기본값 설정**: 선택적 props에 대한 적절한 기본값 +- **커스텀훅**: 로직은 커스텀훅으로 분리함 +- **컴포넌트 분리**: 50줄 이상의 컴포넌트는 분리 고려 +- **테스트 금지**: 컴포넌트 자체는 테스트하지 않아야함 + +```typescript +// 1. 인터페이스 정의 +interface ComponentProps { + required: string + optional?: boolean + children?: React.ReactNode +} + +// 2. 컴포넌트 구현 +export function Component({ + required, + optional = false, + children +}: ComponentProps) { + // 구현 +} +``` + +### 비즈니스 로직 구현 + +- **모듈화**: ESM 모듈 시스템을 활용해 적절히 인터페이스 노출 +- **테스트**: 비즈니스 로직의 경우엔 테스트를 해야함 + +```typescript +// 1. 타입 정의 +export type DataType = { + id: string + value: string +} + +// 2. 로직 함수 구현 +export function processData(data: DataType[]): DataType[] { + // 구현 +} + +// 3. 테스트 작성 +describe('processData', () => { + it('should process data correctly', () => { + // 테스트 구현 + }) +}) +``` + +### Next.js 아키텍처 준수 +- App Router의 이점과 서버 컴포넌트를 적극 활용 + +### 기능 구조 우선 +- 스타일링보다 기능적 구조와 로직에 집중 +- 컴포넌트의 역할과 책임을 명확히 정의 +- 데이터 흐름과 상태 관리 구조 우선 설계 +- UI는 기본적인 레이아웃만 구현하고 세부 스타일링은 후순위 + +### 아키텍처 중심 접근 +- 도메인 로직과 UI 로직의 명확한 분리 +- 컴포넌트 간의 의존성과 데이터 전달 구조 설계 +- 재사용 가능한 로직의 추상화 +- 확장 가능한 구조로 설계 + +### 최소 스타일링 +- 구조화에 필요한 최소한의 스타일링 가능 +- 필요시 shadcn/ui의 컴포넌트를 이용 + +```typescript +// 구조에 집중한 컴포넌트 예시 +
{/* 기본 레이아웃만 */} +
+ {/* 기능적 구조 우선 */} +
+
+
+ {/* ... */} +
+
+
+``` + +### UI 구현 +- **플레이스홀더 사용**: 실제 콘텐츠 대신 `[Page Title]`, `[Description]` 등 사용 +- **사용자 승인**: UI 구조 구현 전 명시적 요구사항 확인 +- **점진적 구현**: 한 번에 모든 기능 구현하지 않기 + +## 접근성 고려사항 + +### 필수 요소 +- 시맨틱 HTML 태그 사용 +- 적절한 ARIA 레이블 +- 키보드 네비게이션 지원 +- 충분한 색상 대비 + +### 구현 예시 +```typescript + +``` + +## 문서화 + +### 코드 주석 +- 복잡한 로직에 대한 설명 주석 +- JSDoc 형태의 함수 문서화 +- 타입 정의에 대한 설명 + +## 형상관리 + +### 브랜치 전략 +- `main`: 프로덕션 준비 코드 +- `feat/`: 새 기능 (`feat/search-functionality`) +- `fix/`: 버그 수정 (`fix/mobile-nav-issue`) +- `refactor/`: 리팩터링 +- `docs/`: 문서 업데이트 + +### 커밋 컨벤션 +``` +: + +[optional body] + +🤖 Generated with Gemini + +Co-Authored-By: Gemini +``` + +**타입:** +- `feat:` - 새 기능 +- `fix:` - 버그 수정 +- `docs:` - 문서 업데이트 +- `config:` - 설정 변경 +- `refactor:` - 리팩토링 +- `chore:` - 유지보수 + +#### 커밋 전략 +논리적 단위로 나누어서 커밋 + +## Development Commands + +- `pnpm dev` - Start development server on localhost:3000 +- `pnpm build` - Build the application for production (configured for static export) +- `pnpm start` - Start production server +- `pnpm test` - Run Vitest +- `pnpm type` - Run tsc +- `pnpm biome:check` - Run Biome formatter and linter +- `pnpm biome:fix` - Run Biome formatter and linter with auto-fix +- `pnpm biome:staged` - Run Biome on staged files only + +**IMPORTANT**: +- Do NOT run `pnpm dev` or `pnpm build` during development tasks unless specifically requested by the user. The build process is mainly for final deployment verification. +- When implementing new pages or components, use placeholders instead of actual content. Show what type of content should go in each position rather than writing fake content. +- Do NOT create UI structures arbitrarily. Always ask the user for specific requirements and approval before implementing any UI design or structure. + +## Gemini Workflow Instructions + +**IMPORTANT**: Gemini must follow this PR-based workflow for ALL development tasks: + +### 1. Before Starting Any Task +```bash +# Ensure you're on main and up to date +git checkout main +git pull origin main + +# Create a new feature branch +git checkout -b / +``` + +### 3. After Completing Work +```bash +# Stage and commit changes LOGICALLY +# DO NOT commit everything at once - break into logical commits +git add [specific files for logical group 1] +git commit -m "conventional commit message for group 1" + +git add [specific files for logical group 2] +git commit -m "conventional commit message for group 2" + +# Push to remote +git push -u origin + +# Create PR immediately +gh pr create --title "PR Title" --body "$(cat <<'EOF' +## Summary +- Brief description of changes +- Key implementation details + +## Checklist +- [ ] Checklist item 1 +- [ ] Checklist item 2 + +🤖 Generated with Gemini +EOF +)" +``` + +**IMPORTANT**: When the user asks to commit changes, NEVER create a single large commit. Always break changes into logical, separate commits such as: +- Documentation changes +- Configuration changes +- New component implementations +- Layout/styling updates +- Bug fixes +Each commit should represent one logical change or feature. + +### 4. PR Requirements +- **Always create PRs**: Never commit directly to main +- **Clear titles**: Use conventional commit format in PR titles +- **Detailed descriptions**: Include Summary and Test plan sections +- **Link issues**: Reference related GitHub issues when applicable + +### 5. Work Scope Guidelines +- **One logical change per PR**: Keep PRs focused and reviewable +- **Complete features**: Don't leave work in broken state +- **Test your changes**: Run `pnpm biome:fix` before committing +- **Document breaking changes**: Clearly explain any breaking changes +- **Use placeholders**: When implementing new pages/components, use placeholders like "[Page Title]", "[Description]", "[Content]" instead of actual content +- **No arbitrary UI**: Do NOT create UI structures without explicit user requirements and approval + +### 6. After PR Creation +- Wait for CI checks to pass +- Address any review feedback +- Merge only after approval (if working in team) or when CI is green (if solo) + +**Remember**: This workflow ensures clean git history, proper code review, and CI validation for all changes. + +### Styling + +- Uses Tailwind CSS with custom configuration +- shadcn/ui for UI components +- CSS variables for theming (light/dark mode support) +- Noto Sans KR font for Korean language support +- Prose styling for blog content rendering +- use stone base color + +### Build Output + +Static files are generated in the `out/` directory during build. From 7284ae785c5ac1de2529400f123465fd6040dda1 Mon Sep 17 00:00:00 2001 From: Jongchan Date: Thu, 17 Jul 2025 15:19:20 +0900 Subject: [PATCH 05/14] =?UTF-8?q?feat:=20categories=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 카테고리 도메인 로직 및 타입 정의 - getAllCategories, getCategoryById 등 핵심 함수 구현 - 카테고리별 포스트 필터링 및 통계 기능 추가 - 카테고리 유효성 검증 로직 포함 - 테스트 케이스 작성으로 안정성 확보 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/entities/categories/categories.test.ts | 168 +++++++++++++++++++++ src/entities/categories/index.ts | 2 + src/entities/categories/logic.ts | 103 +++++++++++++ src/entities/categories/types.ts | 13 ++ 4 files changed, 286 insertions(+) create mode 100644 src/entities/categories/categories.test.ts create mode 100644 src/entities/categories/index.ts create mode 100644 src/entities/categories/logic.ts create mode 100644 src/entities/categories/types.ts diff --git a/src/entities/categories/categories.test.ts b/src/entities/categories/categories.test.ts new file mode 100644 index 0000000..7356c30 --- /dev/null +++ b/src/entities/categories/categories.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest' + +import type { Post } from '@/entities/posts/types' + +import { + getAllCategories, + getCategoriesWithCount, + getCategoryById, + getPostsByCategory, + getPostsWithoutCategory, + isValidCategoryId, +} from './logic' +import type { CategoryId } from './types' + +describe('Categories Logic', () => { + const mockPosts: Post[] = [ + { + slug: 'dev-post-1', + content: 'Content 1', + author: 'Author 1', + data: { + title: 'Dev Post 1', + date: '2025-01-01', + tags: [ + 'React', + 'TypeScript', + ], + description: 'Development post 1', + category: 'dev', + }, + }, + { + slug: 'dev-post-2', + content: 'Content 2', + author: 'Author 2', + data: { + title: 'Dev Post 2', + date: '2025-01-02', + tags: [ + 'Next.js', + ], + description: 'Development post 2', + category: 'dev', + }, + }, + { + slug: 'life-post-1', + content: 'Content 3', + author: 'Author 3', + data: { + title: 'Life Post 1', + date: '2025-01-03', + tags: [ + 'thoughts', + ], + description: 'Life post 1', + category: 'life', + }, + }, + { + slug: 'no-category-post', + content: 'Content 4', + author: 'Author 4', + data: { + title: 'No Category Post', + date: '2025-01-04', + tags: [ + 'misc', + ], + description: 'Post without category', + }, + }, + ] + + describe('getCategoryById', () => { + it('should return category for valid ID', async () => { + const category = await getCategoryById('dev') + expect(category).toBeDefined() + expect(category?.id).toBe('dev') + expect(category?.name).toBe('개발') + }) + + it('should return undefined for invalid ID', async () => { + const category = await getCategoryById('invalid' as CategoryId) + expect(category).toBeUndefined() + }) + }) + + describe('getAllCategories', () => { + it('should return all categories', async () => { + const categories = await getAllCategories() + expect(categories).toHaveLength(2) + expect(categories.map((c) => c.id)).toContain('dev') + expect(categories.map((c) => c.id)).toContain('life') + }) + }) + + describe('getCategoriesWithCount', () => { + it('should return categories with post counts', async () => { + const categoriesWithCount = await getCategoriesWithCount(mockPosts) + + expect(categoriesWithCount).toHaveLength(2) + + const devCategory = categoriesWithCount.find((c) => c.id === 'dev') + expect(devCategory?.count).toBe(2) + + const lifeCategory = categoriesWithCount.find((c) => c.id === 'life') + expect(lifeCategory?.count).toBe(1) + }) + + it('should return zero count for categories with no posts', async () => { + const categoriesWithCount = await getCategoriesWithCount([]) + + expect(categoriesWithCount).toHaveLength(2) + categoriesWithCount.forEach((category) => { + expect(category.count).toBe(0) + }) + }) + }) + + describe('getPostsByCategory', () => { + it('should return posts filtered by category', async () => { + const devPosts = await getPostsByCategory(mockPosts, 'dev') + expect(devPosts).toHaveLength(2) + expect(devPosts.every((post) => post.data.category === 'dev')).toBe(true) + + const lifePosts = getPostsByCategory(mockPosts, 'life') + expect(lifePosts).toHaveLength(1) + expect(lifePosts[0].data.category).toBe('life') + }) + + it('should return empty array for category with no posts', async () => { + const postsWithoutCategory = mockPosts.filter( + (post) => !post.data.category + ) as Post[] + const devPosts = getPostsByCategory(postsWithoutCategory, 'dev') + expect(devPosts).toHaveLength(0) + }) + }) + + describe('isValidCategoryId', () => { + it('should return true for valid category IDs', () => { + expect(isValidCategoryId('dev')).toBe(true) + expect(isValidCategoryId('life')).toBe(true) + }) + + it('should return false for invalid category IDs', () => { + expect(isValidCategoryId('invalid')).toBe(false) + expect(isValidCategoryId('')).toBe(false) + expect(isValidCategoryId('DEV')).toBe(false) + }) + }) + + describe('getPostsWithoutCategory', () => { + it('should return posts without category', () => { + const postsWithoutCategory = getPostsWithoutCategory(mockPosts) + expect(postsWithoutCategory).toHaveLength(1) + expect(postsWithoutCategory[0].slug).toBe('no-category-post') + expect(postsWithoutCategory[0].data.category).toBeUndefined() + }) + + it('should return empty array when all posts have categories', () => { + const postsWithCategories = mockPosts.filter((post) => post.data.category) + const postsWithoutCategory = getPostsWithoutCategory(postsWithCategories) + expect(postsWithoutCategory).toHaveLength(0) + }) + }) +}) diff --git a/src/entities/categories/index.ts b/src/entities/categories/index.ts new file mode 100644 index 0000000..6ad9aa1 --- /dev/null +++ b/src/entities/categories/index.ts @@ -0,0 +1,2 @@ +export * from './logic' +export * from './types' diff --git a/src/entities/categories/logic.ts b/src/entities/categories/logic.ts new file mode 100644 index 0000000..af49110 --- /dev/null +++ b/src/entities/categories/logic.ts @@ -0,0 +1,103 @@ +import fs from 'node:fs/promises' +import path from 'node:path' + +import type { Post } from '@/entities/posts/types' + +import type { Category, CategoryId, CategoryWithCount } from './types' + +const contentsDirectory = path.join(process.cwd(), 'src/contents') + +/** + * 모든 카테고리 목록 조회 + */ +export async function getAllCategories(): Promise { + const categoryDirs = await fs.readdir(contentsDirectory, { + withFileTypes: true, + }) + const categories = await Promise.all( + categoryDirs + .filter((dirent) => dirent.isDirectory()) + .map(async (dirent) => { + const categoryId = dirent.name + const filePath = path.join( + contentsDirectory, + categoryId, + 'category.json' + ) + try { + const fileContent = await fs.readFile(filePath, 'utf-8') + const categoryData = JSON.parse(fileContent) + return { + id: categoryId, + ...categoryData, + } + } catch (error) { + // category.json이 없거나 파싱 오류 시 기본값 사용 + return { + id: categoryId, + name: categoryId, + } + } + }) + ) + return categories +} + +export const categories = await getAllCategories() + +/** + * 카테고리 ID로 카테고리 정보 조회 + */ +export async function getCategoryById( + id: CategoryId +): Promise { + return categories.find((category) => category.id === id) +} + +/** + * 카테고리별 포스트 개수와 함께 카테고리 목록 조회 + */ +export async function getCategoriesWithCount( + posts: Post[] +): Promise { + const categoryCountMap = new Map() + + posts.forEach((post) => { + const categoryId = post.data.category + if (categoryId) { + categoryCountMap.set( + categoryId, + (categoryCountMap.get(categoryId) || 0) + 1 + ) + } + }) + + return categories.map((category) => ({ + ...category, + count: categoryCountMap.get(category.id) || 0, + })) +} + +/** + * 특정 카테고리에 속한 포스트 필터링 + */ +export function getPostsByCategory( + posts: Post[], + categoryId: CategoryId +): Post[] { + return posts.filter((post) => post.data.category === categoryId) +} + +/** + * 카테고리 ID 유효성 검증 + */ +export async function isValidCategoryId(id: string): Promise { + return categories.some((category) => category.id === id) +} + +/** + * 카테고리가 없는 포스트 필터링 + */ +export function getPostsWithoutCategory(posts: Post[]): Post[] { + return posts.filter((post) => !post.data.category) +} diff --git a/src/entities/categories/types.ts b/src/entities/categories/types.ts new file mode 100644 index 0000000..82bc3b6 --- /dev/null +++ b/src/entities/categories/types.ts @@ -0,0 +1,13 @@ +export type Category = { + id: string + name: string + description?: string + color?: string + icon?: string +} + +export type CategoryWithCount = Category & { + count: number +} + +export type CategoryId = string From a3580bc0c30c30def9699bb58c44fe39a851a51c Mon Sep 17 00:00:00 2001 From: Jongchan Date: Thu, 17 Jul 2025 15:20:07 +0900 Subject: [PATCH 06/14] =?UTF-8?q?feat:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EA=B8=B0=EB=B0=98=20=EC=BD=98=ED=85=90=EC=B8=A0=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=A1=9C=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - contents/ 루트에 카테고리별 디렉토리 구조 생성 - src/contents/에서 카테고리별로 포스트 재조직 - dev, life 카테고리용 category.json 설정 파일 추가 - 기존 테스트 포스트들을 적절한 카테고리로 이동 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- contents/dev/category.json | 6 ++++++ contents/life/category.json | 6 ++++++ src/contents/dev/category.json | 6 ++++++ src/contents/{ => dev}/ss.mdx | 2 ++ .../contents/dev/\343\204\264\343\204\264\343\204\264.mdx" | 2 ++ .../contents/dev/\343\205\216\343\205\207.mdx" | 2 ++ src/contents/life/category.json | 6 ++++++ .../contents/life/\343\205\207\343\205\207.mdx" | 2 ++ 8 files changed, 32 insertions(+) create mode 100644 contents/dev/category.json create mode 100644 contents/life/category.json create mode 100644 src/contents/dev/category.json rename src/contents/{ => dev}/ss.mdx (65%) rename "src/contents/\343\204\264\343\204\264\343\204\264.mdx" => "src/contents/dev/\343\204\264\343\204\264\343\204\264.mdx" (62%) rename "src/contents/\343\205\216\343\205\207.mdx" => "src/contents/dev/\343\205\216\343\205\207.mdx" (64%) create mode 100644 src/contents/life/category.json rename "src/contents/\343\205\207\343\205\207.mdx" => "src/contents/life/\343\205\207\343\205\207.mdx" (64%) diff --git a/contents/dev/category.json b/contents/dev/category.json new file mode 100644 index 0000000..252dce7 --- /dev/null +++ b/contents/dev/category.json @@ -0,0 +1,6 @@ +{ + "name": "개발", + "description": "개발 관련 포스트", + "color": "blue", + "icon": "💻" +} diff --git a/contents/life/category.json b/contents/life/category.json new file mode 100644 index 0000000..cf30007 --- /dev/null +++ b/contents/life/category.json @@ -0,0 +1,6 @@ +{ + "name": "일상", + "description": "일상 및 생각 정리", + "color": "green", + "icon": "🌱" +} diff --git a/src/contents/dev/category.json b/src/contents/dev/category.json new file mode 100644 index 0000000..252dce7 --- /dev/null +++ b/src/contents/dev/category.json @@ -0,0 +1,6 @@ +{ + "name": "개발", + "description": "개발 관련 포스트", + "color": "blue", + "icon": "💻" +} diff --git a/src/contents/ss.mdx b/src/contents/dev/ss.mdx similarity index 65% rename from src/contents/ss.mdx rename to src/contents/dev/ss.mdx index 059c91a..334eff8 100644 --- a/src/contents/ss.mdx +++ b/src/contents/dev/ss.mdx @@ -3,6 +3,8 @@ title: 'ss' slug: 'ss' date: 2025-07-13 tags: ['Next.js', 'ㄴㄴㄴ'] +category: 'dev' +description: '테스트 포스트입니다.' --- # ss diff --git "a/src/contents/\343\204\264\343\204\264\343\204\264.mdx" "b/src/contents/dev/\343\204\264\343\204\264\343\204\264.mdx" similarity index 62% rename from "src/contents/\343\204\264\343\204\264\343\204\264.mdx" rename to "src/contents/dev/\343\204\264\343\204\264\343\204\264.mdx" index 23aa8fe..c8d17bd 100644 --- "a/src/contents/\343\204\264\343\204\264\343\204\264.mdx" +++ "b/src/contents/dev/\343\204\264\343\204\264\343\204\264.mdx" @@ -3,6 +3,8 @@ title: 'ㄴㄴㄴㄴ' slug: 'ㄴㄴㄴ' date: 2025-07-13 tags: ['Vue'] +category: 'dev' +description: '한글 slug 테스트용 포스트입니다.' --- # ㄴㄴㄴㄴ diff --git "a/src/contents/\343\205\216\343\205\207.mdx" "b/src/contents/dev/\343\205\216\343\205\207.mdx" similarity index 64% rename from "src/contents/\343\205\216\343\205\207.mdx" rename to "src/contents/dev/\343\205\216\343\205\207.mdx" index 8897b97..722908b 100644 --- "a/src/contents/\343\205\216\343\205\207.mdx" +++ "b/src/contents/dev/\343\205\216\343\205\207.mdx" @@ -3,6 +3,8 @@ title: 'ㅎㅇ' slug: 'ㅎㅇ' date: 2025-07-13 tags: ['React', 'ㄴㄴㄴ'] +category: 'dev' +description: '개발 테스트 포스트입니다.' --- # ㅎㅇ diff --git a/src/contents/life/category.json b/src/contents/life/category.json new file mode 100644 index 0000000..cf30007 --- /dev/null +++ b/src/contents/life/category.json @@ -0,0 +1,6 @@ +{ + "name": "일상", + "description": "일상 및 생각 정리", + "color": "green", + "icon": "🌱" +} diff --git "a/src/contents/\343\205\207\343\205\207.mdx" "b/src/contents/life/\343\205\207\343\205\207.mdx" similarity index 64% rename from "src/contents/\343\205\207\343\205\207.mdx" rename to "src/contents/life/\343\205\207\343\205\207.mdx" index 15ab4ae..87efc7f 100644 --- "a/src/contents/\343\205\207\343\205\207.mdx" +++ "b/src/contents/life/\343\205\207\343\205\207.mdx" @@ -3,6 +3,8 @@ title: 'ㅇㅇ' slug: 'ㅇㅇ' date: 2025-07-13 tags: ['React', 'ㄴㄴㄴ'] +category: 'life' +description: '일상 테스트 포스트입니다.' --- # ㅇㅇ From b682bc0f699cb856c2667e828be4a9150266c299 Mon Sep 17 00:00:00 2001 From: Jongchan Date: Thu, 17 Jul 2025 15:20:26 +0900 Subject: [PATCH 07/14] =?UTF-8?q?feat:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EA=B8=B0=EB=B0=98=20=EB=84=A4=EB=B9=84=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NavLink 컴포넌트 추가로 재사용 가능한 네비게이션 링크 구현 - Header 컴포넌트를 카테고리 기반 네비게이션으로 업데이트 - 동적 카테고리 메뉴 생성으로 확장 가능한 구조 제공 - 카테고리별 포스트 개수 표시 지원 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/app/_components/Header.tsx | 32 +++++++++++++++++++------------- src/app/_components/NavLink.tsx | 16 ++++++++++++++++ 2 files changed, 35 insertions(+), 13 deletions(-) create mode 100644 src/app/_components/NavLink.tsx diff --git a/src/app/_components/Header.tsx b/src/app/_components/Header.tsx index 47a78ed..16db2e0 100644 --- a/src/app/_components/Header.tsx +++ b/src/app/_components/Header.tsx @@ -1,6 +1,14 @@ import Link from 'next/link' -export default function Header() { +import { getCategoriesWithCount } from '@/entities/categories' +import { getAllPosts } from '@/entities/posts' + +import NavLink from './NavLink' + +export default async function Header() { + const posts = await getAllPosts() + const categories = await getCategoriesWithCount(posts) + return (
diff --git a/src/app/_components/NavLink.tsx b/src/app/_components/NavLink.tsx new file mode 100644 index 0000000..9b75d33 --- /dev/null +++ b/src/app/_components/NavLink.tsx @@ -0,0 +1,16 @@ +import Link from 'next/link' +import type { ComponentProps } from 'react' + +interface NavLinkProps extends ComponentProps {} + +export default function NavLink({ href, children, ...props }: NavLinkProps) { + return ( + + {children} + + ) +} From 8e53ba8e80b5eef9998835b952dc4eff68e31482 Mon Sep 17 00:00:00 2001 From: Jongchan Date: Thu, 17 Jul 2025 15:21:37 +0900 Subject: [PATCH 08/14] =?UTF-8?q?feat:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EA=B8=B0=EB=B0=98=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EB=B0=8F=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [category]/[slug] 동적 라우팅으로 카테고리별 포스트 상세 페이지 구현 - [category] 페이지로 카테고리별 포스트 목록 제공 - 기존 posts 컴포넌트들을 카테고리 기반으로 마이그레이션 - PostContent, PostHeader, PostNavigation 등 재사용 가능한 컴포넌트 - CategoryBadge, CategoryList 등 카테고리 전용 컴포넌트 추가 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- contents/dev/category.json | 6 - contents/life/category.json | 6 - src/app/{posts => [category]}/[slug]/page.tsx | 34 ++++-- .../[category]/_components/CategoryBadge.tsx | 53 +++++++++ .../[category]/_components/CategoryList.tsx | 47 ++++++++ .../_components/PostContent.tsx | 0 .../_components/PostFooter.tsx | 15 +-- .../_components/PostHeader.tsx | 13 ++- .../_components/PostNavigation.tsx | 4 +- .../_components/RelatedPostItem.tsx | 2 +- .../_components/RelatedPosts.tsx | 0 .../_components/TagBadge.tsx | 0 .../_components/TagList.tsx | 0 .../_components/index.ts | 5 + src/app/[category]/page.tsx | 106 ++++++++++++++++++ 15 files changed, 248 insertions(+), 43 deletions(-) delete mode 100644 contents/dev/category.json delete mode 100644 contents/life/category.json rename src/app/{posts => [category]}/[slug]/page.tsx (58%) create mode 100644 src/app/[category]/_components/CategoryBadge.tsx create mode 100644 src/app/[category]/_components/CategoryList.tsx rename src/app/{posts => [category]}/_components/PostContent.tsx (100%) rename src/app/{posts => [category]}/_components/PostFooter.tsx (50%) rename src/app/{posts => [category]}/_components/PostHeader.tsx (70%) rename src/app/{posts => [category]}/_components/PostNavigation.tsx (94%) rename src/app/{posts => [category]}/_components/RelatedPostItem.tsx (93%) rename src/app/{posts => [category]}/_components/RelatedPosts.tsx (100%) rename src/app/{posts => [category]}/_components/TagBadge.tsx (100%) rename src/app/{posts => [category]}/_components/TagList.tsx (100%) rename src/app/{posts => [category]}/_components/index.ts (50%) create mode 100644 src/app/[category]/page.tsx diff --git a/contents/dev/category.json b/contents/dev/category.json deleted file mode 100644 index 252dce7..0000000 --- a/contents/dev/category.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "개발", - "description": "개발 관련 포스트", - "color": "blue", - "icon": "💻" -} diff --git a/contents/life/category.json b/contents/life/category.json deleted file mode 100644 index cf30007..0000000 --- a/contents/life/category.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "일상", - "description": "일상 및 생각 정리", - "color": "green", - "icon": "🌱" -} diff --git a/src/app/posts/[slug]/page.tsx b/src/app/[category]/[slug]/page.tsx similarity index 58% rename from src/app/posts/[slug]/page.tsx rename to src/app/[category]/[slug]/page.tsx index 5f6027e..c016306 100644 --- a/src/app/posts/[slug]/page.tsx +++ b/src/app/[category]/[slug]/page.tsx @@ -6,16 +6,20 @@ import { PostHeader, PostNavigation, RelatedPosts, -} from '@/app/posts/_components' +} from '@/app/[category]/_components' +import { type CategoryId, isValidCategoryId } from '@/entities/categories' import { getAllPosts, getPostNavigation } from '@/entities/posts' import { getRelatedPostsByTags } from '@/entities/tags' export async function generateStaticParams() { const posts = await getAllPosts() - const params = posts.map((post) => ({ - slug: encodeURIComponent(post.slug), - })) + const params = posts + .filter((post) => post.data.category) // 카테고리가 있는 포스트만 + .map((post) => ({ + category: post.data.category as CategoryId, + slug: encodeURIComponent(post.slug), + })) return params } @@ -24,17 +28,29 @@ export default async function PostPage({ params, }: { params: Promise<{ + category: string slug: string }> }) { - const { slug } = await params + const { category, slug } = await params const decodedSlug = decodeURIComponent(slug) + // 카테고리 유효성 검증 + if (!isValidCategoryId(category)) { + notFound() + } + try { + // 카테고리 기반 경로로 MDX 파일 import const { default: Post, frontmatter } = await import( - `@/contents/${decodedSlug}.mdx` + `@/contents/${category}/${decodedSlug}` ) + // 포스트의 실제 카테고리와 URL 카테고리가 일치하는지 확인 + if (frontmatter.category && frontmatter.category !== category) { + notFound() + } + const { previousPost, nextPost } = await getPostNavigation(decodedSlug) const relatedPosts = await getRelatedPostsByTags(decodedSlug) @@ -44,11 +60,7 @@ export default async function PostPage({ - + + {showIcon && category.icon && ( + + )} + {category.name} + + ) +} diff --git a/src/app/[category]/_components/CategoryList.tsx b/src/app/[category]/_components/CategoryList.tsx new file mode 100644 index 0000000..abbeef9 --- /dev/null +++ b/src/app/[category]/_components/CategoryList.tsx @@ -0,0 +1,47 @@ +import { cn } from '@/app/_lib/cn' +import { + type CategoryWithCount, + getAllCategories, + getCategoriesWithCount, +} from '@/entities/categories' + +import { CategoryBadge } from './CategoryBadge' + +interface CategoryListProps { + categories?: CategoryWithCount[] + showCount?: boolean + className?: string +} + +export function CategoryList({ + categories, + showCount = false, + className, +}: CategoryListProps) { + const displayCategories = + categories || + getAllCategories().map((cat) => ({ + ...cat, + count: 0, + })) + + if (displayCategories.length === 0) { + return null + } + + return ( +
+ {displayCategories.map((category) => ( +
+ + {showCount && ( + ({category.count}) + )} +
+ ))} +
+ ) +} diff --git a/src/app/posts/_components/PostContent.tsx b/src/app/[category]/_components/PostContent.tsx similarity index 100% rename from src/app/posts/_components/PostContent.tsx rename to src/app/[category]/_components/PostContent.tsx diff --git a/src/app/posts/_components/PostFooter.tsx b/src/app/[category]/_components/PostFooter.tsx similarity index 50% rename from src/app/posts/_components/PostFooter.tsx rename to src/app/[category]/_components/PostFooter.tsx index 665c007..48fdf18 100644 --- a/src/app/posts/_components/PostFooter.tsx +++ b/src/app/[category]/_components/PostFooter.tsx @@ -1,24 +1,11 @@ import { cn } from '@/app/_lib/cn' -import type { Post } from '@/entities/posts/types' - -import { PostNavigation } from './PostNavigation' -import { RelatedPosts } from './RelatedPosts' interface PostFooterProps { - previousPost?: Pick - nextPost?: Pick - relatedPosts?: Array> author?: string className?: string } -export function PostFooter({ - previousPost, - nextPost, - relatedPosts, - author, - className, -}: PostFooterProps) { +export function PostFooter({ author, className }: PostFooterProps) { return (