diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37a6ee8..42bcf58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,4 +33,4 @@ jobs: run: pnpm tsc --noEmit - name: Run tests - run: pnpm test:run \ No newline at end of file + run: pnpm test \ No newline at end of file diff --git a/.kiro/steering/project-structure.md b/.kiro/steering/project-structure.md new file mode 100644 index 0000000..6012217 --- /dev/null +++ b/.kiro/steering/project-structure.md @@ -0,0 +1,91 @@ +# 프로젝트 구조 및 개발 가이드라인 + +## 프로젝트 개요 + +Next.js 15 기반의 정적 블로그 애플리케이션으로, MDX를 사용한 콘텐츠 관리와 도메인 주도 설계를 따릅니다. + +## 기술 스택 + +- **프레임워크**: Next.js 15 (App Router) +- **콘텐츠**: MDX with rehype-pretty-code +- **스타일링**: Tailwind CSS + shadcn/ui +- **언어**: TypeScript +- **코드 품질**: Biome (formatting/linting) +- **테스팅**: Vitest +- **패키지 매니저**: pnpm + +## 디렉토리 구조 + +``` +src/ +├── app/ # Next.js App Router 루트 +│ ├── _components/ # 전역 컴포넌트 +│ ├── _fonts/ # 폰트 설정 +│ ├── _lib/ # 유틸리티 함수 +│ ├── posts/[slug]/ # 동적 포스트 페이지 +│ ├── about/ # About 페이지 +│ ├── layout.tsx # 루트 레이아웃 +│ └── globals.css # 전역 스타일 +├── entities/ # 도메인 엔티티 +│ ├── posts/ # 포스트 도메인 로직 +│ └── tags/ # 태그 도메인 로직 +└── contents/ # MDX 블로그 포스트 +``` + +## 네이밍 컨벤션 + +- **라우트가 아닌 폴더**: 언더스코어 접두사 사용 (`_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'] +--- + +# 포스트 내용 +``` + +## 배포 설정 + +- **basePath**: `/blog` (GitHub Pages 등을 위한 설정) +- **출력**: 정적 파일 (`out/` 디렉토리) +- **환경**: Node.js 환경에서 빌드, 정적 호스팅 가능 \ No newline at end of file diff --git a/.kiro/steering/task-execution-process.md b/.kiro/steering/task-execution-process.md new file mode 100644 index 0000000..5784d53 --- /dev/null +++ b/.kiro/steering/task-execution-process.md @@ -0,0 +1,170 @@ +# 작업 실행 프로세스 + +## 작업 진행 원칙 + +- 작업의 단위를 가능하면 작게 설정 +- 지시가 모호하다고 느껴지면 질문 후 진행 + +## 구현 원칙 + +### 점진적 개발 +- 한 번에 하나의 태스크만 집중하여 구현 +- 태스크 완료 후 사용자 검토 대기, 자동으로 다음 태스크 진행하지 않음 +- 각 단계에서 이전 단계의 결과물을 기반으로 구축 + +### 코드 품질 +- 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) { + // 구현 +} +``` + +### 비즈니스 로직 구현 +```typescript +// 1. 타입 정의 +export type DataType = { + id: string + value: string +} + +// 2. 로직 함수 구현 +export function processData(data: DataType[]): DataType[] { + // 구현 +} + +// 3. 테스트 작성 +describe('processData', () => { + it('should process data correctly', () => { + // 테스트 구현 + }) +}) +``` + +### 기능 구조 우선 +- 스타일링보다 기능적 구조와 로직에 집중 +- 컴포넌트의 역할과 책임을 명확히 정의 +- 데이터 흐름과 상태 관리 구조 우선 설계 +- UI는 기본적인 레이아웃만 구현하고 세부 스타일링은 후순위 + +### 아키텍처 중심 접근 +- 도메인 로직과 UI 로직의 명확한 분리 +- 컴포넌트 간의 의존성과 데이터 전달 구조 설계 +- 재사용 가능한 로직의 추상화 +- 확장 가능한 구조로 설계 + +### 최소 스타일링 +- 구조화에 필요한 최소한의 스타일링 가능 +- 필요시 shadcn/ui의 컴포넌트를 이용 + +```typescript +// 구조에 집중한 컴포넌트 예시 +
{/* 기본 레이아웃만 */} +
+ {/* 기능적 구조 우선 */} +
+
+
+ {/* ... */} +
+
+
+``` + +### UI 구현 +- **플레이스홀더 사용**: 실제 콘텐츠 대신 `[Page Title]`, `[Description]` 등 사용 +- **사용자 승인**: UI 구조 구현 전 명시적 요구사항 확인 +- **점진적 구현**: 한 번에 모든 기능 구현하지 않기 + +## 에러 처리 + +### 클라이언트 사이드 +- 사용자 친화적 에러 메시지 +- 적절한 폴백 UI 제공 +- 개발 환경에서 상세 로깅 + +### 서버 사이드 +- notFound() 함수 활용 +- 적절한 에러 바운더리 설정 +- Graceful degradation 적용 + +## 접근성 고려사항 + +### 필수 요소 +- 시맨틱 HTML 태그 사용 +- 적절한 ARIA 레이블 +- 키보드 네비게이션 지원 +- 충분한 색상 대비 + +### 구현 예시 +```typescript + + + + +// Card 컴포넌트 예시 + + + 카드 제목 + + + 카드 내용 + + +``` + +## 레이아웃 패턴 + +### 컨테이너 +```typescript +// 기본 컨테이너 +
+ {/* 콘텐츠 */} +
+ +// 최대 너비 제한 +
+ {/* 콘텐츠 */} +
+``` + +### 그리드 시스템 +```typescript +// 반응형 그리드 +
+ {/* 그리드 아이템들 */} +
+ +// 플렉스 레이아웃 +
+ {/* 플렉스 아이템들 */} +
+``` + +## 반응형 디자인 + +### 브레이크포인트 +```css +sm: 640px /* 태블릿 */ +md: 768px /* 작은 데스크톱 */ +lg: 1024px /* 데스크톱 */ +xl: 1280px /* 큰 데스크톱 */ +2xl: 1536px /* 매우 큰 화면 */ +``` + +### 모바일 퍼스트 접근 +```typescript +// 모바일 기본, 태블릿 이상에서 변경 +
+ 반응형 텍스트 +
+ +// 모바일에서 숨김, 데스크톱에서 표시 +
+ 데스크톱 전용 콘텐츠 +
+``` + +## 상태 및 인터랙션 + +### 호버 상태 +```typescript + +``` + +### 포커스 상태 +```typescript + +``` + +### 로딩 상태 +```typescript + +``` + +## 다크 모드 + +### 구현 방식 +- CSS 변수 기반 테마 시스템 +- `dark:` 접두사로 다크 모드 스타일 정의 +- 시스템 설정 자동 감지 + +### 사용 예시 +```typescript +
+ 다크 모드 지원 콘텐츠 +
+``` + +## 애니메이션 + +### 기본 트랜지션 +```typescript +// 색상 변화 +
+ +// 크기 변화 +
+ +// 투명도 변화 +
+``` + +### 페이지 전환 +- 부드러운 페이지 전환 효과 +- 로딩 상태 표시 +- 스켈레톤 UI 활용 + +## 아이콘 시스템 + +### Lucide React 사용 +```typescript +import { Search, Menu, X, ChevronRight } from 'lucide-react' + +// 일관된 크기 사용 + + +``` + +### 아이콘 가이드라인 +- **크기**: `w-4 h-4` (16px), `w-5 h-5` (20px), `w-6 h-6` (24px) +- **색상**: 텍스트 색상과 동일하게 유지 +- **의미**: 직관적이고 일반적으로 인식되는 아이콘 사용 + +## 에러 및 피드백 + +### 에러 메시지 +```typescript +
+ 에러 메시지 +
+``` + +### 성공 메시지 +```typescript +
+ 성공 메시지 +
+``` + +### 정보 메시지 +```typescript +
+ 정보 메시지 +
+``` \ No newline at end of file diff --git a/biome.json b/biome.json index 575be15..7f8e0b2 100644 --- a/biome.json +++ b/biome.json @@ -9,10 +9,12 @@ "files": { "ignoreUnknown": false, "includes": [ - "**/*", - "!.next", "!node_modules", - "!public" + "!public", + "!.*", + "**/*.ts", + "**/*.tsx", + "**/*.json" ] }, "formatter": { diff --git a/package.json b/package.json index 1a63d2d..9daf13e 100644 --- a/package.json +++ b/package.json @@ -6,13 +6,14 @@ "dev": "next dev", "build": "next build", "start": "next start", - "test": "vitest", + "test": "vitest run", "test:ui": "vitest --ui", - "test:run": "vitest run", + "test:watch": "vitest watch", "test:coverage": "vitest run --coverage", "biome:check": "biome check --verbose", "biome:fix": "biome check --write --verbose", - "biome:staged": "biome check --write --staged" + "biome:staged": "biome check --write --staged", + "type": "tsc --noEmit" }, "dependencies": { "@mdx-js/loader": "^3.1.0", diff --git a/src/app/_components/ui/badge.tsx b/src/app/_components/ui/badge.tsx new file mode 100644 index 0000000..24b0444 --- /dev/null +++ b/src/app/_components/ui/badge.tsx @@ -0,0 +1,53 @@ +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' +import type * as React from 'react' + +import { cn } from '@/app/_lib/cn' + +const badgeVariants = cva( + 'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden', + { + variants: { + variant: { + default: + 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90', + secondary: + 'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90', + destructive: + 'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', + outline: + 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +) + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<'span'> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : 'span' + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/src/app/_components/ui/card.tsx b/src/app/_components/ui/card.tsx new file mode 100644 index 0000000..a8b9fbf --- /dev/null +++ b/src/app/_components/ui/card.tsx @@ -0,0 +1,92 @@ +import type * as React from 'react' + +import { cn } from '@/app/_lib/cn' + +function Card({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/src/app/_lib/formatDate.test.ts b/src/app/_lib/formatDate.test.ts new file mode 100644 index 0000000..a3e3e5c --- /dev/null +++ b/src/app/_lib/formatDate.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' + +import { formatDate, formatRelativeDate } from './formatDate' + +describe('formatDate', () => { + it('should format ISO date string to Korean format', () => { + const isoDate = '2025-01-15' + const result = formatDate(isoDate) + expect(result).toBe('2025년 1월 15일') + }) + + it('should format Date object to Korean format', () => { + const date = new Date('2025-12-25') + const result = formatDate(date) + expect(result).toBe('2025년 12월 25일') + }) + + it('should handle single digit months and days', () => { + const isoDate = '2025-03-05' + const result = formatDate(isoDate) + expect(result).toBe('2025년 3월 5일') + }) + + it('should return original string for invalid date', () => { + const invalidDate = 'invalid-date' + const result = formatDate(invalidDate) + expect(result).toBe('invalid-date') + }) +}) + +describe('formatRelativeDate', () => { + it('should return "오늘" for today', () => { + const today = new Date() + const result = formatRelativeDate(today) + expect(result).toBe('오늘') + }) + + it('should return "어제" for yesterday', () => { + const yesterday = new Date() + yesterday.setDate(yesterday.getDate() - 1) + const result = formatRelativeDate(yesterday) + expect(result).toBe('어제') + }) + + it('should return days for recent dates', () => { + const threeDaysAgo = new Date() + threeDaysAgo.setDate(threeDaysAgo.getDate() - 3) + const result = formatRelativeDate(threeDaysAgo) + expect(result).toBe('3일 전') + }) + + it('should return weeks for dates within a month', () => { + const twoWeeksAgo = new Date() + twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14) + const result = formatRelativeDate(twoWeeksAgo) + expect(result).toBe('2주일 전') + }) +}) diff --git a/src/app/_lib/formatDate.ts b/src/app/_lib/formatDate.ts new file mode 100644 index 0000000..5d140fe --- /dev/null +++ b/src/app/_lib/formatDate.ts @@ -0,0 +1,50 @@ +/** + * Formats a date string to Korean format + * @param dateString - ISO date string (YYYY-MM-DD) or Date object + * @returns Formatted date string in Korean format (YYYY년 M월 D일) + */ +export function formatDate(dateString: string | Date): string { + const date = + typeof dateString === 'string' ? new Date(dateString) : dateString + + // Check if date is valid + if (Number.isNaN(date.getTime())) { + return dateString.toString() + } + + const year = date.getFullYear() + const month = date.getMonth() + 1 + const day = date.getDate() + + return `${year}년 ${month}월 ${day}일` +} + +/** + * Formats a date string to relative time (e.g., "2일 전", "1주일 전") + * @param dateString - ISO date string (YYYY-MM-DD) or Date object + * @returns Relative time string in Korean + */ +export function formatRelativeDate(dateString: string | Date): string { + const date = + typeof dateString === 'string' ? new Date(dateString) : dateString + const now = new Date() + const diffInMs = now.getTime() - date.getTime() + const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24)) + + if (diffInDays === 0) { + return '오늘' + } else if (diffInDays === 1) { + return '어제' + } else if (diffInDays < 7) { + return `${diffInDays}일 전` + } else if (diffInDays < 30) { + const weeks = Math.floor(diffInDays / 7) + return `${weeks}주일 전` + } else if (diffInDays < 365) { + const months = Math.floor(diffInDays / 30) + return `${months}개월 전` + } else { + const years = Math.floor(diffInDays / 365) + return `${years}년 전` + } +} diff --git a/src/app/globals.css b/src/app/globals.css index e9f7055..50ca9ef 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -119,3 +119,49 @@ @apply bg-background text-foreground; } } + +@layer components { + /* Enhanced code block styling for better mobile experience */ + .prose pre { + /* Ensure proper scrolling on mobile */ + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; + scrollbar-color: theme(colors.stone.400) theme(colors.stone.100); + } + + .dark .prose pre { + scrollbar-color: theme(colors.stone.600) theme(colors.stone.800); + } + + /* Custom scrollbar for webkit browsers */ + .prose pre::-webkit-scrollbar { + height: 8px; + width: 8px; + } + + .prose pre::-webkit-scrollbar-track { + background: theme(colors.stone.100); + border-radius: 4px; + } + + .prose pre::-webkit-scrollbar-thumb { + background: theme(colors.stone.400); + border-radius: 4px; + } + + .prose pre::-webkit-scrollbar-thumb:hover { + background: theme(colors.stone.500); + } + + .dark .prose pre::-webkit-scrollbar-track { + background: theme(colors.stone.800); + } + + .dark .prose pre::-webkit-scrollbar-thumb { + background: theme(colors.stone.600); + } + + .dark .prose pre::-webkit-scrollbar-thumb:hover { + background: theme(colors.stone.500); + } +} diff --git a/src/app/blog/[slug]/page.tsx b/src/app/posts/[slug]/page.tsx similarity index 64% rename from src/app/blog/[slug]/page.tsx rename to src/app/posts/[slug]/page.tsx index 96c4d0a..2cb5832 100644 --- a/src/app/blog/[slug]/page.tsx +++ b/src/app/posts/[slug]/page.tsx @@ -1,5 +1,6 @@ import { notFound } from 'next/navigation' +import { PostContent, PostFooter, PostHeader } from '@/app/posts/_components' import { getAllPosts } from '@/entities/posts' export async function generateStaticParams() { @@ -22,13 +23,17 @@ export default async function PostPage({ const { slug } = await params try { - const { default: Post } = await import( + const { default: Post, frontmatter } = await import( `@/contents/${decodeURIComponent(slug)}.mdx` ) return ( -
- +
+ + + + +
) } catch (_error) { diff --git a/src/app/posts/_components/PostContent.tsx b/src/app/posts/_components/PostContent.tsx new file mode 100644 index 0000000..c0f0496 --- /dev/null +++ b/src/app/posts/_components/PostContent.tsx @@ -0,0 +1,38 @@ +import { cn } from '@/app/_lib/cn' +import type { Heading } from '@/entities/posts/types' + +interface PostContentProps { + children: React.ReactNode + showTOC?: boolean + headings?: Heading[] + className?: string +} + +export function PostContent({ + children, + showTOC = false, + headings, + className, +}: PostContentProps) { + return ( +
+ {showTOC && headings && ( + + )} +
+ {children} +
+
+ ) +} diff --git a/src/app/posts/_components/PostFooter.tsx b/src/app/posts/_components/PostFooter.tsx new file mode 100644 index 0000000..6a2e432 --- /dev/null +++ b/src/app/posts/_components/PostFooter.tsx @@ -0,0 +1,41 @@ +import { cn } from '@/app/_lib/cn' +import type { Post } from '@/entities/posts/types' + +interface PostFooterProps { + previousPost?: Pick + nextPost?: Pick + relatedPosts?: Array> + author?: string + className?: string +} + +export function PostFooter({ + previousPost, + nextPost, + relatedPosts, + author, + className, +}: PostFooterProps) { + return ( +
+ {/* TODO: Implement PostNavigation component */} + {(previousPost || nextPost) && ( +
+
Navigation (TODO)
+
+ )} + + {/* TODO: Implement RelatedPosts component */} + {relatedPosts && relatedPosts.length > 0 && ( +
+
Related Posts (TODO)
+
+ )} + + {/* TODO: Implement SocialShare component */} +
+
Social Share (TODO)
+
+
+ ) +} diff --git a/src/app/posts/_components/PostHeader.tsx b/src/app/posts/_components/PostHeader.tsx new file mode 100644 index 0000000..c6c6b59 --- /dev/null +++ b/src/app/posts/_components/PostHeader.tsx @@ -0,0 +1,60 @@ +import { Badge } from '@/app/_components/ui/badge' +import { cn } from '@/app/_lib/cn' +import { formatDate } from '@/app/_lib/formatDate' + +interface PostHeaderProps { + title: string + date: string + tags: string[] + readingTime?: number + author?: string + className?: string +} + +export function PostHeader({ + title, + date, + tags, + readingTime, + author, + className, +}: PostHeaderProps) { + return ( +
+

+ {title} +

+
+ + {readingTime && ( + 약 {readingTime}분 읽기 + )} + {author && 작성자: {author}} + {tags.length > 0 && ( +
+ {tags.map((tag) => ( + + #{tag} + + ))} +
+ )} +
+
+ ) +} diff --git a/src/app/posts/_components/index.ts b/src/app/posts/_components/index.ts new file mode 100644 index 0000000..37a1a4c --- /dev/null +++ b/src/app/posts/_components/index.ts @@ -0,0 +1,3 @@ +export { PostContent } from './PostContent' +export { PostFooter } from './PostFooter' +export { PostHeader } from './PostHeader' diff --git a/src/contents/app-router-getstaticparams-404.mdx b/src/contents/app-router-getstaticparams-404.mdx index 87a01fa..846acfc 100644 --- a/src/contents/app-router-getstaticparams-404.mdx +++ b/src/contents/app-router-getstaticparams-404.mdx @@ -5,8 +5,6 @@ date: 2025-07-14 tags: ['Next.js', 'App Router', '404'] --- -# App Router에서 getStaticParams와 404 페이지 처리하기 - Next.js 13부터 도입된 App Router에서는 동적 라우팅과 정적 생성을 위한 새로운 방식이 도입되었습니다. 이번 글에서는 `generateStaticParams` 함수와 404 페이지 처리에 대해 알아보겠습니다. ## generateStaticParams란? diff --git a/src/entities/posts/types.ts b/src/entities/posts/types.ts index e20deb8..bb79cd9 100644 --- a/src/entities/posts/types.ts +++ b/src/entities/posts/types.ts @@ -4,6 +4,10 @@ export type PostFrontMatter = { title: string date: string tags: Array + description?: string + author?: string + readingTime?: number + featured?: boolean } export type Post = { @@ -15,3 +19,33 @@ export type Post = { export type PostGrayMatter = GrayMatterFile & { data: PostFrontMatter } + +export type PostWithNavigation = Post & { + previousPost?: Pick + nextPost?: Pick + relatedPosts?: Array> +} + +export type Heading = { + id: string + text: string + level: number +} + +export type PostMetadata = { + title: string + description: string + openGraph: { + title: string + description: string + type: 'article' + publishedTime: string + authors: string[] + tags: string[] + } + twitter: { + card: 'summary_large_image' + title: string + description: string + } +}