From b9c8026e354fa5ba36fd9a9a502b6e1d50de048d Mon Sep 17 00:00:00 2001 From: Jongchan Date: Fri, 18 Jul 2025 15:31:14 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20Table=20of=20Contents=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 포스트 페이지에 인터랙티브한 목차 기능을 추가하여 사용자 경험을 개선했습니다. 주요 기능: - MDX 헤딩 자동 추출 및 ID 생성 - 스크롤 스파이를 통한 현재 위치 표시 - 부드러운 스크롤 네비게이션 - 반응형 디자인 (데스크톱 사이드바, 모바일 상단) - 접근성 개선 (ARIA 라벨, 키보드 네비게이션) 기술적 구현: - extractHeadingsFromMDX(): 정규식 기반 헤딩 파싱 - TableOfContents: IntersectionObserver 활용 스크롤 스파이 - mdx-components: 헤딩 요소에 자동 ID 주입 - 계층적 들여쓰기 및 시각적 구분 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/app/[category]/[slug]/page.tsx | 10 +- .../[category]/_components/PostContent.tsx | 52 +++++--- .../_components/TableOfContents.tsx | 114 ++++++++++++++++++ src/app/[category]/_components/index.ts | 1 + src/domain/blog/index.ts | 4 + src/domain/blog/logic/headings.ts | 88 ++++++++++++++ src/mdx-components.tsx | 74 ++++++++++++ 7 files changed, 325 insertions(+), 18 deletions(-) create mode 100644 src/app/[category]/_components/TableOfContents.tsx create mode 100644 src/domain/blog/logic/headings.ts diff --git a/src/app/[category]/[slug]/page.tsx b/src/app/[category]/[slug]/page.tsx index bdfc478..962f7b8 100644 --- a/src/app/[category]/[slug]/page.tsx +++ b/src/app/[category]/[slug]/page.tsx @@ -9,6 +9,7 @@ import { } from '@/app/[category]/_components' import { type CategoryId, + extractHeadingsFromMDX, getPostNavigation, getRelatedPostsByTags, isValidCategoryId, @@ -55,13 +56,20 @@ export default async function PostPage({ notFound() } + // 포스트 콘텐츠에서 헤딩 추출 (실제 포스트 내용 필요) + const postData = posts.find((post) => post.slug === decodedSlug) + const headings = postData ? extractHeadingsFromMDX(postData.content) : [] + const { previousPost, nextPost } = getPostNavigation(decodedSlug) const relatedPosts = getRelatedPostsByTags(decodedSlug) return (
- + 0} + headings={headings} + > diff --git a/src/app/[category]/_components/PostContent.tsx b/src/app/[category]/_components/PostContent.tsx index 10db526..746b0c2 100644 --- a/src/app/[category]/_components/PostContent.tsx +++ b/src/app/[category]/_components/PostContent.tsx @@ -1,6 +1,8 @@ import { cn } from '@/app/_lib/cn' import type { Heading } from '@/domain/blog' +import { TableOfContents } from './TableOfContents' + interface PostContentProps { children: React.ReactNode showTOC?: boolean @@ -15,24 +17,40 @@ export function PostContent({ className, }: PostContentProps) { return ( -
- {showTOC && headings && ( - + <> + {/* 모바일 TOC - 펼침/접기 가능한 형태 */} + {showTOC && headings && headings.length > 0 && ( +
+ +
)} -
+ {/* 데스크톱 TOC - 사이드바 */} + {showTOC && headings && headings.length > 0 && ( + )} - > - {children} -
-
+ +
0 + ? 'lg:col-span-3' + : 'lg:col-span-4' + )} + > + {children} +
+
+ ) } diff --git a/src/app/[category]/_components/TableOfContents.tsx b/src/app/[category]/_components/TableOfContents.tsx new file mode 100644 index 0000000..3379483 --- /dev/null +++ b/src/app/[category]/_components/TableOfContents.tsx @@ -0,0 +1,114 @@ +'use client' + +import { useEffect, useState } from 'react' + +import { cn } from '@/app/_lib/cn' +import type { Heading } from '@/domain/blog' + +interface TableOfContentsProps { + headings: Heading[] + className?: string +} + +export function TableOfContents({ headings, className }: TableOfContentsProps) { + const [activeId, setActiveId] = useState('') + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + // 가장 위에 있는 헤딩을 찾아서 활성화 + const visibleHeadings = entries + .filter((entry) => entry.isIntersecting) + .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top) + + if (visibleHeadings.length > 0) { + setActiveId(visibleHeadings[0].target.id) + } + }, + { + rootMargin: '-80px 0px -80px 0px', // 헤더 높이 고려 + threshold: 0.5, + } + ) + + // 모든 헤딩 요소 관찰 시작 + headings.forEach((heading) => { + const element = document.getElementById(heading.id) + if (element) { + observer.observe(element) + } + }) + + return () => observer.disconnect() + }, [ + headings, + ]) + + const handleClick = (id: string) => { + const element = document.getElementById(id) + if (element) { + element.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }) + } + } + + if (headings.length === 0) { + return null + } + + return ( + + ) +} diff --git a/src/app/[category]/_components/index.ts b/src/app/[category]/_components/index.ts index 7c13245..47d0930 100644 --- a/src/app/[category]/_components/index.ts +++ b/src/app/[category]/_components/index.ts @@ -6,5 +6,6 @@ export { PostHeader } from './PostHeader' export { PostNavigation } from './PostNavigation' export { RelatedPostItem } from './RelatedPostItem' export { RelatedPosts } from './RelatedPosts' +export { TableOfContents } from './TableOfContents' export { TagBadge } from './TagBadge' export { TagList } from './TagList' diff --git a/src/domain/blog/index.ts b/src/domain/blog/index.ts index 645cc0d..71f2ade 100644 --- a/src/domain/blog/index.ts +++ b/src/domain/blog/index.ts @@ -8,6 +8,7 @@ import { getAllCategories, validateCategoryId, } from './logic/categories' +import { extractHeadingsFromMDX, generateHeadingId } from './logic/headings' import { filterPostsByTag, findAdjacentPosts, getAllPosts } from './logic/posts' import { analyzeTagRelationships, @@ -95,6 +96,9 @@ export function getRelatedPostsByTags(currentSlug: string, limit: number = 3) { export { allTags as tags } +// ===== Headings API ===== +export { extractHeadingsFromMDX, generateHeadingId } + // ===== Default exports ===== export default { posts: allPosts, diff --git a/src/domain/blog/logic/headings.ts b/src/domain/blog/logic/headings.ts new file mode 100644 index 0000000..ba347d2 --- /dev/null +++ b/src/domain/blog/logic/headings.ts @@ -0,0 +1,88 @@ +import type { Heading } from '../types' + +/** + * MDX 콘텐츠에서 헤딩을 추출합니다. + * + * @param content - MDX 문자열 콘텐츠 + * @returns 추출된 헤딩 배열 + */ +export function extractHeadingsFromMDX(content: string): Heading[] { + const headings: Heading[] = [] + + // MDX 헤딩 패턴 매칭 (# ## ### #### ##### ######) + const headingRegex = /^(#{1,6})\s+(.+)$/gm + let match: RegExpExecArray | null + + match = headingRegex.exec(content) + while (match !== null) { + const level = match[1].length // # 개수로 레벨 결정 + const text = match[2].trim() + + // 헤딩 텍스트에서 ID 생성 (kebab-case) + const id = generateHeadingId(text) + + headings.push({ + id, + text, + level, + }) + + match = headingRegex.exec(content) + } + + return headings +} + +/** + * 헤딩 텍스트에서 URL에 안전한 ID를 생성합니다. + * + * @param text - 헤딩 텍스트 + * @returns kebab-case 형식의 ID + */ +export function generateHeadingId(text: string): string { + return text + .toLowerCase() + .replace(/[^\w\s가-힣]/g, '') // 영문, 숫자, 공백, 한글만 유지 + .replace(/\s+/g, '-') // 공백을 하이픈으로 변경 + .replace(/-+/g, '-') // 연속된 하이픈 제거 + .replace(/^-|-$/g, '') // 시작/끝 하이픈 제거 +} + +/** + * 헤딩 배열을 중첩된 트리 구조로 변환합니다. + * + * @param headings - 플랫한 헤딩 배열 + * @returns 중첩된 헤딩 트리 + */ +export type HeadingTree = Heading & { + children: HeadingTree[] +} + +export function buildHeadingTree(headings: Heading[]): HeadingTree[] { + const tree: HeadingTree[] = [] + const stack: HeadingTree[] = [] + + for (const heading of headings) { + const node: HeadingTree = { + ...heading, + children: [], + } + + // 현재 헤딩 레벨보다 큰 레벨들을 스택에서 제거 + while (stack.length > 0 && stack[stack.length - 1].level >= heading.level) { + stack.pop() + } + + // 스택이 비어있으면 루트 레벨 + if (stack.length === 0) { + tree.push(node) + } else { + // 부모 노드의 자식으로 추가 + stack[stack.length - 1].children.push(node) + } + + stack.push(node) + } + + return tree +} diff --git a/src/mdx-components.tsx b/src/mdx-components.tsx index 8ed99fb..fc740a5 100644 --- a/src/mdx-components.tsx +++ b/src/mdx-components.tsx @@ -1,7 +1,81 @@ import type { MDXComponents } from 'mdx/types' +import { generateHeadingId } from '@/domain/blog' + export function useMDXComponents(components: MDXComponents): MDXComponents { return { + h1: ({ children, ...props }) => { + const text = typeof children === 'string' ? children : String(children) + const id = generateHeadingId(text) + return ( +

+ {children} +

+ ) + }, + h2: ({ children, ...props }) => { + const text = typeof children === 'string' ? children : String(children) + const id = generateHeadingId(text) + return ( +

+ {children} +

+ ) + }, + h3: ({ children, ...props }) => { + const text = typeof children === 'string' ? children : String(children) + const id = generateHeadingId(text) + return ( +

+ {children} +

+ ) + }, + h4: ({ children, ...props }) => { + const text = typeof children === 'string' ? children : String(children) + const id = generateHeadingId(text) + return ( +

+ {children} +

+ ) + }, + h5: ({ children, ...props }) => { + const text = typeof children === 'string' ? children : String(children) + const id = generateHeadingId(text) + return ( +
+ {children} +
+ ) + }, + h6: ({ children, ...props }) => { + const text = typeof children === 'string' ? children : String(children) + const id = generateHeadingId(text) + return ( +
+ {children} +
+ ) + }, ...components, } } From 819aaf5c38e56c7122aeef4866d04174d892605d Mon Sep 17 00:00:00 2001 From: Jongchan Date: Tue, 22 Jul 2025 11:22:15 +0900 Subject: [PATCH 2/4] =?UTF-8?q?docs:=20=EC=A7=80=EC=B9=A8=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=8B=9C=EC=8A=A4=ED=85=9C=EC=9D=84=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EC=A4=91=EC=8B=AC=EC=9C=BC=EB=A1=9C=20=EA=B0=9C?= =?UTF-8?q?=ED=8E=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 장황한 문서들을 간결하고 실행 가능한 체크리스트 중심으로 전면 재구성: - development-guidelines.md: 157줄→51줄 압축, 핵심 원칙과 품질 체크리스트 중심 - git-workflow.md: TodoWrite 강제화로 논리적 단위 커밋 보장 - quality-checklist.md: 새로 추가, 커밋/PR/개발 단계별 필수 검증 항목 - CLAUDE.md: 작업 원칙 간소화, 핵심 체크 항목 명시 - current-status.md: 실질적 내용 없어 제거 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .context/current-status.md | 13 --- .context/development-guidelines.md | 164 +++++------------------------ .context/git-workflow.md | 33 +++--- .context/quality-checklist.md | 41 ++++++++ CLAUDE.md | 20 ++-- 5 files changed, 97 insertions(+), 174 deletions(-) delete mode 100644 .context/current-status.md create mode 100644 .context/quality-checklist.md diff --git a/.context/current-status.md b/.context/current-status.md deleted file mode 100644 index 9d11ae2..0000000 --- a/.context/current-status.md +++ /dev/null @@ -1,13 +0,0 @@ -# current-status.md - -최근 완료 작업과 다음 작업 요소를 정리한 문서입니다. - -## 최근 완료 작업 -- ✅ **CLAUDE.md 문서 체계화**: @import 구문으로 모듈화된 문서 구조 완성 -- ✅ **development-guidelines.md 최적화**: 실제 프로젝트의 도메인 중심 설계, 모듈 레벨 캐싱, 태그 그래프 시스템 패턴 중심으로 재작성 -- ✅ **styling-guide.md 최적화**: 실제 컴포넌트 패턴(CategoryBadge, TagBadge, CVA 시스템) 반영한 실용적 가이드 -- ✅ **git-workflow.md 최적화**: 실제 프로젝트의 브랜치 패턴과 커밋 컨벤션 반영 -- ✅ **commands.md 최적화**: 실제 package.json 스크립트 기반, 개발서버/빌드 금지 지침 포함한 실용적 명령어 가이드 - -## 다음 작업 요소 -- 추후 정의 diff --git a/.context/development-guidelines.md b/.context/development-guidelines.md index c99f9f9..8e72097 100644 --- a/.context/development-guidelines.md +++ b/.context/development-guidelines.md @@ -1,157 +1,51 @@ # 개발 가이드라인 -이 블로그 프로젝트의 핵심 패턴과 구현 원칙을 정의합니다. +## 필수 원칙 -## 아키텍처 패턴 +### 도메인 API 사용 +- ✅ `import { getCategoryById } from '@/domain/blog'` +- ❌ 직접 파일 시스템 접근 금지 -### 도메인 중심 설계 -- **도메인 로직**: `src/domain/blog/` 에 통합 -- **UI 컴포넌트**: `src/app/` 하위에 구현 -- **타입 정의**: `src/domain/blog/types.ts` 중앙 집중 +### 타입 안전성 +- ✅ 모든 Props 인터페이스 명시 +- ❌ `any` 타입 사용 금지 +- ✅ 도메인 타입 재사용 (`CategoryId`, `Post`, `Tag`) -```typescript -// 도메인 API 사용 예시 -import { getCategoryById, getPostsByCategory, getRelatedPostsByTags } from '@/domain/blog' - -const category = getCategoryById('dev') -const posts = getPostsByCategory('dev') -const related = getRelatedPostsByTags(currentSlug, 3) -``` - -### 모듈 레벨 캐싱 -빌드 타임에 모든 데이터를 사전 로드하여 SSG 최적화 - -```typescript -// src/domain/blog/index.ts -export const allPosts = await getAllPosts() -export const allCategories = await getAllCategories() -export const allTags = extractTagsFromPosts(allPosts) -export const tagGraph = createTagGraph(allPosts, allTags) -export const tagClusters = createTagClusters(tagGraph) -``` - -### 태그 그래프 시스템 -graphology 라이브러리로 태그 간 관계 분석 - -```typescript -// 태그 관계 분석 -const relationships = getTagRelationships() -const clusters = getTagClusters() -const relatedPosts = getRelatedPostsByTags(currentSlug, 3) -``` +### 아키텍처 구조 +- **도메인 로직**: `src/domain/blog/` 통합 +- **UI 컴포넌트**: `src/app/` 분리 +- **모듈 레벨 캐싱**: 빌드 타임 데이터 사전 로드 ## 콘텐츠 구조 -### MDX 파일 구조 +### MDX 파일 ``` src/contents/ -├── dev/ # 개발 카테고리 -│ ├── category.json -│ └── *.mdx -└── life/ # 일상 카테고리 - ├── category.json - └── *.mdx +├── dev/category.json + *.mdx +└── life/category.json + *.mdx ``` -### 포스트 frontmatter +### frontmatter 필수 필드 ```yaml ---- title: '포스트 제목' date: 2025-01-17 tags: ['tag1', 'tag2'] -description: '포스트 설명' -category: 'dev' # 또는 'life' ---- -``` - -### 카테고리 메타데이터 -```json -{ - "name": "개발", - "description": "개발 관련 포스트", - "color": "blue", - "icon": "💻" -} -``` - -## 컴포넌트 패턴 - -### 타입 정의 -```typescript -interface PostHeaderProps { - title: string - date: string - tags: string[] - category?: CategoryId - readingTime?: number - author?: string - className?: string -} -``` - -### 컴포넌트 구현 -```typescript -// 서버 컴포넌트 기본, 상호작용 필요시만 클라이언트 -export function PostHeader({ title, date, tags, category }: PostHeaderProps) { - return ( -
-
- {category && } -

{title}

-
- -
- ) -} +category: 'dev' # 또는 'life' ``` -## 품질 기준 +## 품질 체크리스트 -### TypeScript 엄격 모드 -- `any` 타입 사용 금지 -- 모든 Props 인터페이스 명시적 정의 -- 도메인 타입 재사용 (`CategoryId`, `Post`, `Tag`) +### 컴포넌트 작성시 +- [ ] Props 인터페이스 정의 +- [ ] 서버 컴포넌트 우선 (상호작용 필요시만 클라이언트) +- [ ] `cn()` 유틸리티로 클래스 병합 -### 테스트 패턴 -```typescript -// 비즈니스 로직 테스트 -describe('태그 그래프 시스템', () => { - it('관련 포스트를 태그 유사도로 찾는다', () => { - const related = getRelatedPostsByTags('test-slug', 3) - expect(related).toHaveLength(3) - }) -}) -``` +### 데이터 처리시 +- [ ] 도메인 API 함수 사용 +- [ ] 타입 안전성 보장 +- [ ] 빌드 타임 사전 계산 활용 ### 성능 최적화 -- 모든 데이터 빌드 타임 사전 계산 -- `generateStaticParams()` 사용한 정적 경로 생성 -- 컴포넌트 간 props 최소화 - -## 디렉토리 구조 - -``` -src/ -├── app/ # Next.js App Router -│ ├── [category]/ # 카테고리 페이지 -│ │ ├── [slug]/ # 포스트 상세 -│ │ └── _components/ # 페이지별 컴포넌트 -│ └── _components/ # 전역 컴포넌트 -├── domain/blog/ # 도메인 로직 -│ ├── index.ts # 공개 API + 캐싱 -│ ├── types.ts # 타입 정의 -│ └── logic/ # 비즈니스 로직 -│ ├── posts.ts -│ ├── categories.ts -│ └── tags.ts -└── contents/ # MDX 콘텐츠 - ├── dev/ - └── life/ -``` - -## 구현 원칙 - -1. **도메인 API 사용**: 직접 파일 시스템 접근 금지 -2. **타입 안전성**: 모든 데이터 흐름 타입 보장 -3. **모듈 레벨 캐싱**: 빌드 타임 데이터 사전 로드 -4. **컴포넌트 단순화**: 단일 책임 원칙 \ No newline at end of file +- [ ] `generateStaticParams()` 정적 경로 생성 +- [ ] 컴포넌트 간 props 최소화 +- [ ] 모듈 레벨 캐싱 활용 \ No newline at end of file diff --git a/.context/git-workflow.md b/.context/git-workflow.md index c4aaf94..53e5bb7 100644 --- a/.context/git-workflow.md +++ b/.context/git-workflow.md @@ -11,21 +11,9 @@ - **docs/**: 문서 작업용 브랜치 ### 브랜치 네이밍 -```bash -# 기능 개발 (실제 프로젝트 패턴) -feat/entities -feat/post-navigation -feat/entity-optimization-and-category-system - -# 버그 수정 -fix/resolve-navigation-issue -fix/correct-typo-in-header - -# 문서/설정 작업 -docs/claude-pr-workflow -config/github-templates -config/tailwind-setup -``` +- `feat/feature-name` - 기능 개발 +- `fix/issue-name` - 버그 수정 +- `docs/document-name` - 문서 작업 ## 커밋 컨벤션 @@ -49,7 +37,20 @@ footer (optional) ## 논리적 단위 커밋 -- 커밋은 변경사항 그룹끼리 묶여서 작성 +### 기본 원칙 +- **하나의 커밋 = 하나의 논리적 변경사항** +- 서로 다른 기능/목적의 변경사항은 별도 커밋으로 분리 +- 각 커밋은 독립적으로 동작할 수 있어야 함 + +### 커밋 워크플로우 (TodoWrite 필수) +1. **변경사항 분석**: `git status`, `git diff` 확인 +2. **TodoWrite로 논리적 단위 계획**: 각 기능별 커밋 목록 작성 +3. **단계별 커밋**: 할일 완료하며 순차 실행 +4. **진행 추적**: TodoWrite에서 완료 체크 + +### 올바른 커밋 분리 +- ✅ 기능별 분리: 메타데이터 / RSS / 레이아웃 +- ❌ 모든 변경사항을 한 번에 커밋 ## PR 프로세스 diff --git a/.context/quality-checklist.md b/.context/quality-checklist.md new file mode 100644 index 0000000..9726ceb --- /dev/null +++ b/.context/quality-checklist.md @@ -0,0 +1,41 @@ +# 품질 체크리스트 + +## 커밋 전 필수 체크 + +### TodoWrite 사용 +- [ ] 여러 변경사항이 있을 때 TodoWrite로 논리적 단위 계획 +- [ ] 각 커밋 단위별로 할일 목록 작성 +- [ ] 완료된 항목 실시간 체크 + +### 코드 품질 +- [ ] `pnpm biome:check` 통과 +- [ ] `pnpm type` 통과 +- [ ] `pnpm test` 통과 + +### 아키텍처 준수 +- [ ] 도메인 API 사용 (`@/domain/blog` import) +- [ ] `any` 타입 사용 없음 +- [ ] Props 인터페이스 정의 + +## PR 생성 전 체크 + +### 문서 업데이트 +- [ ] 변경된 영역의 CLAUDE.md 업데이트 +- [ ] 새로운 기능/패턴이 있으면 가이드라인 반영 + +### 최종 검증 +- [ ] 모든 커밋이 논리적 단위로 분리됨 +- [ ] 커밋 메시지가 명확함 +- [ ] 테스트 통과 확인 + +## 일상 개발 체크 + +### 컴포넌트 작성 +- [ ] 서버 컴포넌트 우선 (상호작용 필요시만 클라이언트) +- [ ] `cn()` 유틸리티로 클래스 병합 +- [ ] TypeScript 엄격 모드 준수 + +### 성능 최적화 +- [ ] `generateStaticParams()` 정적 경로 생성 +- [ ] 모듈 레벨 캐싱 활용 +- [ ] 불필요한 props 전달 최소화 \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index eb0b71e..399216e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,18 +69,18 @@ Next.js 15 기반 정적 블로그의 아키텍처 지침과 작업 가이드입 - @.context/git-workflow.md - 브랜치 전략, 커밋 컨벤션 ### 참조 정보 -- @.context/current-status.md - 최신 작업 상태 - @.context/project-overview.md - 기술 스택, 상세 구조 - @.context/commands.md - 개발 명령어, 빌드 설정 +- @.context/quality-checklist.md - 품질 체크리스트 -## 🚀 작업 흐름 +## 🚀 작업 원칙 -1. **작업 영역 파악**: 수정할 파일의 디렉토리 확인 -2. **컨텍스트 확보**: 해당 영역의 CLAUDE.md 및 상위 문서 읽기 -3. **아키텍처 준수**: 도메인 분리, 타입 안전성, 성능 원칙 적용 -4. **문서 업데이트**: 작업 완료 후 해당 CLAUDE.md 즉시 업데이트 -5. **상위 영향 확인**: 변경사항이 상위 아키텍처에 미치는 영향 검토 +### 필수 체크 +- **TodoWrite 사용**: 복잡한 작업시 할일 목록으로 계획 +- **스코프별 CLAUDE.md 업데이트**: 작업 완료 후 즉시 반영 +- **품질 검증**: @.context/quality-checklist.md 준수 ---- - -💡 **Tip**: 새로운 기능 개발 시 먼저 해당 영역의 CLAUDE.md를 확인하고, 기존 패턴을 따라 일관성을 유지하세요. \ No newline at end of file +### 아키텍처 준수 +- **도메인 API 사용**: 직접 파일 시스템 접근 금지 +- **타입 안전성**: `any` 금지, 인터페이스 명시 +- **논리적 단위 커밋**: TodoWrite로 계획 후 분리 커밋 \ No newline at end of file From 47b15a25abd767f666d85e2512a2fe18687ef0d2 Mon Sep 17 00:00:00 2001 From: Jongchan Date: Tue, 22 Jul 2025 11:22:54 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=EB=A9=94=ED=83=80=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EB=B0=8F=20?= =?UTF-8?q?RSS=20=ED=94=BC=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 포스트, 카테고리, 홈페이지별 동적 메타데이터 생성과 RSS 구독 기능 완성: - metadata.ts: 페이지별 SEO 메타데이터 생성 유틸리티 - rss.ts: RSS/Atom 피드 생성 로직 - generateMetadata: 각 페이지에 동적 메타데이터 적용 - /rss.xml, /atom.xml: 구독 가능한 피드 라우트 - layout.tsx: RSS 링크 및 기본 메타데이터 설정 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/app/[category]/[slug]/page.tsx | 76 +++++++++++++++---- src/app/[category]/page.tsx | 27 ++++++- src/app/_lib/metadata.ts | 117 +++++++++++++++++++++++++++++ src/app/_lib/rss.ts | 101 +++++++++++++++++++++++++ src/app/atom.xml/route.ts | 30 ++++++++ src/app/layout.tsx | 57 ++++++++++++-- src/app/page.tsx | 14 +--- src/app/rss.xml/route.ts | 30 ++++++++ 8 files changed, 422 insertions(+), 30 deletions(-) create mode 100644 src/app/_lib/metadata.ts create mode 100644 src/app/_lib/rss.ts create mode 100644 src/app/atom.xml/route.ts create mode 100644 src/app/rss.xml/route.ts diff --git a/src/app/[category]/[slug]/page.tsx b/src/app/[category]/[slug]/page.tsx index 962f7b8..28c1078 100644 --- a/src/app/[category]/[slug]/page.tsx +++ b/src/app/[category]/[slug]/page.tsx @@ -1,15 +1,19 @@ +import type { Metadata } from 'next' import { notFound } from 'next/navigation' +import { createPostMetadata } from '@/app/_lib/metadata' import { PostContent, PostFooter, PostHeader, PostNavigation, RelatedPosts, + TableOfContents, } from '@/app/[category]/_components' import { type CategoryId, extractHeadingsFromMDX, + getCategoryById, getPostNavigation, getRelatedPostsByTags, isValidCategoryId, @@ -27,6 +31,48 @@ export function generateStaticParams() { return params } +export async function generateMetadata({ + params, +}: { + params: Promise<{ + category: string + slug: string + }> +}): Promise { + const { category, slug } = await params + const decodedSlug = decodeURIComponent(slug) + + // 카테고리 유효성 검증 + if (!isValidCategoryId(category)) { + return {} + } + + try { + // 카테고리 기반 경로로 MDX 파일 import + const { frontmatter } = await import( + `@/contents/${category}/${decodedSlug}.mdx` + ) + + // 포스트의 실제 카테고리와 URL 카테고리가 일치하는지 확인 + if (frontmatter.category && frontmatter.category !== category) { + return {} + } + + // 포스트 데이터 찾기 + const postData = posts.find((post) => post.slug === decodedSlug) + if (!postData) { + return {} + } + + // 카테고리 정보 가져오기 + const categoryInfo = getCategoryById(category) + + return createPostMetadata(postData, categoryInfo?.name) + } catch (_error) { + return {} + } +} + export default async function PostPage({ params, }: { @@ -64,21 +110,23 @@ export default async function PostPage({ const relatedPosts = getRelatedPostsByTags(decodedSlug) return ( -
+ <> - 0} - headings={headings} - > - - - - - -
+
+
+ + + + + + +
+
+ + ) } catch (_error) { console.error(_error) diff --git a/src/app/[category]/page.tsx b/src/app/[category]/page.tsx index adcb1ef..f9e362e 100644 --- a/src/app/[category]/page.tsx +++ b/src/app/[category]/page.tsx @@ -1,6 +1,8 @@ +import type { Metadata } from 'next' import Link from 'next/link' import { notFound } from 'next/navigation' +import { createCategoryMetadata } from '@/app/_lib/metadata' import { categories, getCategoryById, @@ -16,6 +18,29 @@ export function generateStaticParams() { })) } +export async function generateMetadata({ + params, +}: { + params: Promise<{ + category: string + }> +}): Promise { + const { category } = await params + + if (!isValidCategoryId(category)) { + return {} + } + + const categoryInfo = getCategoryById(category) + const categoryPosts = getPostsByCategory(category) + + if (!categoryInfo) { + return {} + } + + return createCategoryMetadata(categoryInfo, categoryPosts.length) +} + export default async function CategoryPage({ params, }: { @@ -37,7 +62,7 @@ export default async function CategoryPage({ } return ( -
+
diff --git a/src/app/_lib/metadata.ts b/src/app/_lib/metadata.ts new file mode 100644 index 0000000..4c2c4dc --- /dev/null +++ b/src/app/_lib/metadata.ts @@ -0,0 +1,117 @@ +import type { Metadata } from 'next' + +import type { Category, Post } from '@/domain/blog' + +const SITE_NAME = 'Kayce Blog' +const SITE_DESCRIPTION = '개발 경험과 일상의 생각들을 기록하는 블로그입니다.' +const SITE_URL = 'https://kickbelldev.github.com/blog' + +export function createMetadata({ + title, + description, + path = '', + type = 'website', + publishedTime, + authors = [ + 'Kayce', + ], + tags = [], + section, +}: { + title: string + description: string + path?: string + type?: 'website' | 'article' + publishedTime?: string + authors?: string[] + tags?: string[] + section?: string +}): Metadata { + const url = `${SITE_URL}${path}` + + return { + title, + description, + openGraph: { + title, + description, + url, + type, + siteName: SITE_NAME, + locale: 'ko_KR', + ...(type === 'article' && { + publishedTime, + authors, + tags, + section, + }), + }, + twitter: { + card: 'summary_large_image', + title, + description, + }, + keywords: tags.length > 0 ? tags : undefined, + } +} + +export function createPostMetadata( + post: Post, + categoryName?: string +): Metadata { + const { title, description, date, tags, category } = post.data + const url = `/${category || 'uncategorized'}/${post.slug}` + + return createMetadata({ + title, + description, + path: url, + type: 'article', + publishedTime: new Date(date).toISOString(), + authors: [ + post.author || 'Kayce', + ], + tags, + section: categoryName, + }) +} + +export function createCategoryMetadata( + category: Category, + postCount: number +): Metadata { + const title = `${category.name} - ${SITE_NAME}` + const description = + category.description || + `${category.name} 카테고리의 포스트들을 확인해보세요.` + const enhancedDescription = `${description} (${postCount}개 포스트)` + + return createMetadata({ + title, + description: enhancedDescription, + path: `/${category.id}`, + }) +} + +export function createHomeMetadata(): Metadata { + return createMetadata({ + title: `${SITE_NAME} - 개발과 일상의 기록`, + description: SITE_DESCRIPTION, + }) +} + +export function createTagsMetadata(): Metadata { + return createMetadata({ + title: `태그 목록 - ${SITE_NAME}`, + description: '블로그의 모든 태그를 확인하고 관련 포스트를 찾아보세요.', + path: '/tags', + }) +} + +export function createAboutMetadata(): Metadata { + return createMetadata({ + title: `About - ${SITE_NAME}`, + description: '블로그 운영자 Kayce에 대한 소개와 연락 정보입니다.', + path: '/about', + }) +} diff --git a/src/app/_lib/rss.ts b/src/app/_lib/rss.ts new file mode 100644 index 0000000..34cfd34 --- /dev/null +++ b/src/app/_lib/rss.ts @@ -0,0 +1,101 @@ +import type { Post } from '@/domain/blog' + +const SITE_URL = 'https://kickbelldev.github.com/blog' +const SITE_NAME = 'Kayce Blog' +const SITE_DESCRIPTION = '개발 경험과 일상의 생각들을 기록하는 블로그입니다.' + +export function generateRSSFeed(posts: Post[]): string { + const rssItems = posts + .slice(0, 20) // 최신 20개 포스트만 + .map((post) => { + const postUrl = `${SITE_URL}/${post.data.category || 'uncategorized'}/${post.slug}` + const pubDate = new Date(post.data.date).toUTCString() + + return ` + + <![CDATA[${post.data.title}]]> + + ${postUrl} + ${postUrl} + ${pubDate} + noreply@kickbelldev.github.com (Kayce Kim) + ${post.data.category ? `${post.data.category}` : ''} + ${post.data.tags?.map((tag) => `${tag}`).join('') || ''} + `.trim() + }) + .join('\n') + + const lastBuildDate = new Date().toUTCString() + const pubDate = + posts.length > 0 + ? new Date(posts[0].data.date).toUTCString() + : lastBuildDate + + return ` + + + ${SITE_NAME} + ${SITE_DESCRIPTION} + ${SITE_URL} + ko-KR + ${lastBuildDate} + ${pubDate} + 1440 + + noreply@kickbelldev.github.com (Kayce Kim) + noreply@kickbelldev.github.com (Kayce Kim) + Copyright ${new Date().getFullYear()} Kayce Kim. All rights reserved. + Blog + Development + Personal + Next.js RSS Generator +${rssItems} + +`.trim() +} + +export function generateAtomFeed(posts: Post[]): string { + const atomEntries = posts + .slice(0, 20) + .map((post) => { + const postUrl = `${SITE_URL}/${post.data.category || 'uncategorized'}/${post.slug}` + const updated = new Date(post.data.date).toISOString() + + return ` + + <![CDATA[${post.data.title}]]> + + ${updated} + ${postUrl} + + + Kayce Kim + noreply@kickbelldev.github.com + + ${post.data.category ? `` : ''} + ${post.data.tags?.map((tag) => ``).join('') || ''} + `.trim() + }) + .join('\n') + + const updated = + posts.length > 0 + ? new Date(posts[0].data.date).toISOString() + : new Date().toISOString() + + return ` + + ${SITE_NAME} + ${SITE_DESCRIPTION} + + + ${updated} + ${SITE_URL}/ + + Kayce Kim + noreply@kickbelldev.github.com + + Next.js +${atomEntries} +`.trim() +} diff --git a/src/app/atom.xml/route.ts b/src/app/atom.xml/route.ts new file mode 100644 index 0000000..8b95d47 --- /dev/null +++ b/src/app/atom.xml/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server' + +import { generateAtomFeed } from '@/app/_lib/rss' +import { allPosts } from '@/domain/blog' + +export const dynamic = 'force-static' + +export async function GET() { + try { + // 날짜순으로 정렬된 포스트들 + const sortedPosts = allPosts.sort( + (a, b) => + new Date(b.data.date).getTime() - new Date(a.data.date).getTime() + ) + + const atomXml = generateAtomFeed(sortedPosts) + + return new NextResponse(atomXml, { + status: 200, + headers: { + 'Content-Type': 'application/atom+xml; charset=utf-8', + }, + }) + } catch (error) { + console.error('Atom feed generation error:', error) + return new NextResponse('Error generating Atom feed', { + status: 500, + }) + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 6da8ca6..845ef51 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -7,8 +7,41 @@ import { notoSansKR } from './_fonts/notoSansKR' import './globals.css' export const metadata: Metadata = { - title: '[Site Title]', - description: '[Site Description]', + title: { + template: '%s | Kayce Blog', + default: 'Kayce Blog - Personal Records', + }, + authors: [ + { + name: 'Kayce Kim', + url: 'https://github.com/kickbelldev', + }, + ], + creator: 'Kayce Kim', + publisher: 'Kayce Kim', + metadataBase: new URL('https://kickbelldev.github.com/blog'), + openGraph: { + type: 'website', + locale: 'ko_KR', + url: 'https://kickbelldev.github.com/blog', + title: 'Kayce Blog - Personal Records', + siteName: 'Kayce Blog', + }, + twitter: { + card: 'summary_large_image', + title: 'Kayce Blog - Personal Records', + }, + robots: { + index: true, + follow: true, + googleBot: { + index: true, + follow: true, + 'max-video-preview': -1, + 'max-image-preview': 'large', + 'max-snippet': -1, + }, + }, } export default function RootLayout({ @@ -18,10 +51,24 @@ export default function RootLayout({ }>) { return ( + + + + -
-
-
{children}
+
+
+
{children}
diff --git a/src/app/page.tsx b/src/app/page.tsx index c73debe..7bccc1b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,6 +1,8 @@ +import type { Metadata } from 'next' import Link from 'next/link' import { formatDate } from '@/app/_lib/formatDate' +import { createHomeMetadata } from '@/app/_lib/metadata' import { allPosts, allTags, @@ -8,6 +10,8 @@ import { type Post, } from '@/domain/blog' +export const metadata: Metadata = createHomeMetadata() + export default function Home() { const categories = getCategoriesWithCount() const recentPosts = allPosts.slice(0, 6) @@ -15,16 +19,6 @@ export default function Home() { return (
- {/* Hero Section */} -
-

- 개발과 삶의 이야기 -

-

- 개발 경험, 기술 학습, 그리고 일상의 인사이트를 공유하는 공간입니다. -

-
- {/* Categories Section */}

카테고리

diff --git a/src/app/rss.xml/route.ts b/src/app/rss.xml/route.ts new file mode 100644 index 0000000..a39a8f2 --- /dev/null +++ b/src/app/rss.xml/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server' + +import { generateRSSFeed } from '@/app/_lib/rss' +import { allPosts } from '@/domain/blog' + +export const dynamic = 'force-static' + +export async function GET() { + try { + // 날짜순으로 정렬된 포스트들 + const sortedPosts = allPosts.sort( + (a, b) => + new Date(b.data.date).getTime() - new Date(a.data.date).getTime() + ) + + const rssXml = generateRSSFeed(sortedPosts) + + return new NextResponse(rssXml, { + status: 200, + headers: { + 'Content-Type': 'application/rss+xml; charset=utf-8', + }, + }) + } catch (error) { + console.error('RSS feed generation error:', error) + return new NextResponse('Error generating RSS feed', { + status: 500, + }) + } +} From d4723a70876517bb350a152b8f5b03f57cc9f1cc Mon Sep 17 00:00:00 2001 From: Jongchan Date: Tue, 22 Jul 2025 11:23:26 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20UI/=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 경험과 성능을 위한 레이아웃 및 컴포넌트 최적화: - TableOfContents: 서버 컴포넌트로 리팩토링, 고정 사이드바 구현 - PostContent/Header: 간소화된 구조로 성능 개선 - Header: z-index 최적화로 TOC와 겹침 방지 - NavLink: 활성 상태 시각화 추가 - Footer: RSS 링크 추가 - 레이아웃: max-width 4xl→5xl 확장으로 가독성 향상 - Hero 섹션: 제거하여 콘텐츠 중심 홈페이지 구성 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .kiro/steering/ui-design-system.md | 2 +- pnpm-lock.yaml | 52 ++++++++++---- .../[category]/_components/PostContent.tsx | 56 +++------------ src/app/[category]/_components/PostHeader.tsx | 9 +-- .../_components/TableOfContents.tsx | 68 +++---------------- src/app/_components/Footer.tsx | 10 ++- src/app/_components/Header.tsx | 2 +- src/app/_components/NavLink.tsx | 12 +++- 8 files changed, 83 insertions(+), 128 deletions(-) diff --git a/.kiro/steering/ui-design-system.md b/.kiro/steering/ui-design-system.md index c991333..73ef4e7 100644 --- a/.kiro/steering/ui-design-system.md +++ b/.kiro/steering/ui-design-system.md @@ -144,7 +144,7 @@ text-4xl: 36px / 40px
// 최대 너비 제한 -
+
{/* 콘텐츠 */}
``` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22daa92..f2314eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -119,7 +119,7 @@ importers: version: 5.8.3 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.7)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(yaml@2.8.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.7)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0) packages: @@ -1333,6 +1333,9 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + get-tsconfig@4.10.1: + resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==} + glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} hasBin: true @@ -1960,6 +1963,9 @@ packages: remark-rehype@11.1.2: resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + rollup@4.45.0: resolution: {integrity: sha512-WLjEcJRIo7i3WDDgOIJqVI2d+lAC3EwvOGy+Xfq6hs+GQuAA4Di/H72xmXkOhrIWFg2PFYSKZYfH0f4vfKXN4A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -2167,6 +2173,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.20.3: + resolution: {integrity: sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==} + engines: {node: '>=18.0.0'} + hasBin: true + tw-animate-css@1.3.5: resolution: {integrity: sha512-t3u+0YNoloIhj1mMXs779P6MO9q3p3mvGn4k1n3nJPqJw/glZcuijG2qTSN4z4mgNRfW5ZC3aXJFLwDtiipZXA==} @@ -3049,7 +3060,7 @@ snapshots: std-env: 3.9.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.7)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(yaml@2.8.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.7)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0) transitivePeerDependencies: - supports-color @@ -3061,13 +3072,13 @@ snapshots: chai: 5.2.1 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.0.4(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0))': + '@vitest/mocker@3.2.4(vite@7.0.4(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 7.0.4(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0) + vite: 7.0.4(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -3098,7 +3109,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.7)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(yaml@2.8.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.7)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0) '@vitest/utils@3.2.4': dependencies: @@ -3405,6 +3416,11 @@ snapshots: fsevents@2.3.3: optional: true + get-tsconfig@4.10.1: + dependencies: + resolve-pkg-maps: 1.0.0 + optional: true + glob@10.4.5: dependencies: foreground-child: 3.3.1 @@ -4339,6 +4355,9 @@ snapshots: unified: 11.0.5 vfile: 6.0.3 + resolve-pkg-maps@1.0.0: + optional: true + rollup@4.45.0: dependencies: '@types/estree': 1.0.8 @@ -4572,6 +4591,14 @@ snapshots: tslib@2.8.1: {} + tsx@4.20.3: + dependencies: + esbuild: 0.25.6 + get-tsconfig: 4.10.1 + optionalDependencies: + fsevents: 2.3.3 + optional: true + tw-animate-css@1.3.5: {} typescript@5.8.3: {} @@ -4657,13 +4684,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 - vite-node@3.2.4(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0): + vite-node@3.2.4(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0): dependencies: cac: 6.7.14 debug: 4.4.1 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.0.4(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0) + vite: 7.0.4(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0) transitivePeerDependencies: - '@types/node' - jiti @@ -4678,7 +4705,7 @@ snapshots: - tsx - yaml - vite@7.0.4(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0): + vite@7.0.4(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0): dependencies: esbuild: 0.25.6 fdir: 6.4.6(picomatch@4.0.2) @@ -4691,13 +4718,14 @@ snapshots: fsevents: 2.3.3 jiti: 2.4.2 lightningcss: 1.30.1 + tsx: 4.20.3 yaml: 2.8.0 - vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.7)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(yaml@2.8.0): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.7)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.0.4(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0)) + '@vitest/mocker': 3.2.4(vite@7.0.4(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -4715,8 +4743,8 @@ snapshots: tinyglobby: 0.2.14 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.0.4(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0) - vite-node: 3.2.4(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0) + vite: 7.0.4(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0) + vite-node: 3.2.4(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 diff --git a/src/app/[category]/_components/PostContent.tsx b/src/app/[category]/_components/PostContent.tsx index 746b0c2..da57b08 100644 --- a/src/app/[category]/_components/PostContent.tsx +++ b/src/app/[category]/_components/PostContent.tsx @@ -1,56 +1,20 @@ import { cn } from '@/app/_lib/cn' -import type { Heading } from '@/domain/blog' - -import { TableOfContents } from './TableOfContents' interface PostContentProps { children: React.ReactNode - showTOC?: boolean - headings?: Heading[] - className?: string } -export function PostContent({ - children, - showTOC = false, - headings, - className, -}: PostContentProps) { +export function PostContent({ children }: PostContentProps) { return ( - <> - {/* 모바일 TOC - 펼침/접기 가능한 형태 */} - {showTOC && headings && headings.length > 0 && ( -
- -
+
- {/* 데스크톱 TOC - 사이드바 */} - {showTOC && headings && headings.length > 0 && ( - - )} - -
0 - ? 'lg:col-span-3' - : 'lg:col-span-4' - )} - > - {children} -
-
- + > + {children} + ) } diff --git a/src/app/[category]/_components/PostHeader.tsx b/src/app/[category]/_components/PostHeader.tsx index f97b353..ad6d6be 100644 --- a/src/app/[category]/_components/PostHeader.tsx +++ b/src/app/[category]/_components/PostHeader.tsx @@ -1,4 +1,3 @@ -import { cn } from '@/app/_lib/cn' import { formatDate } from '@/app/_lib/formatDate' import type { CategoryId } from '@/domain/blog' @@ -12,7 +11,6 @@ interface PostHeaderProps { category?: CategoryId readingTime?: number author?: string - className?: string } export function PostHeader({ @@ -22,15 +20,12 @@ export function PostHeader({ category, readingTime, author, - className, }: PostHeaderProps) { return ( -
+
{category && } -

- {title} -

+

{title}