# portfolio-blog 기획서

> 2026-07-06 기준. 대화로 확정한 결정 사항 + 웹 검증(2026년 최신 패키지/관행 실측 확인)을 거친 최종 아키텍처.

## 목표

네이버 블로그·티스토리·브런치처럼 **글을 읽기 좋게 보여주는 개인 블로그** 사이트.
우선 블로그 기능만 개발하고, 이후 포트폴리오·프로젝트 소개·About 페이지를 추가한다.
디자인은 **Vercel Geist 디자인 시스템**(https://vercel.com/design.md)을 기준으로 하되,
최종 적용은 나중에 하므로 **토큰(CSS 변수) 레이어로 분리해 값 교체만으로 반영**되게 한다.

## 확정된 결정 사항

| 항목 | 결정 | 비고 |
|---|---|---|
| 글 작성 방식 | 레포 안의 순수 마크다운(.md) | git push = 발행. MDX 아님 |
| 배포처 | Vercel | 무료 티어, Next.js 네이티브 |
| 프레임워크 | Next.js (App Router) + TypeScript + pnpm | 서버 기능(조회수 등) 확장 용이 |
| 레이아웃 기준 | 티스토리 기본 스킨 스타일 | 넓은 본문 + 상단 헤더 네비게이션 |
| 글 URL | 파일명 = 영문 slug | 예: `my-first-post.md` → `/blog/my-first-post` |
| 카테고리 | 2단계 계층 (대분류 > 소분류, 티스토리식) | + 선택적 태그 축 |
| 디자인 | Geist(design.md) 토큰 | https://vercel.com/design.md·design.dark.md 실측 적용 완료 (2026-07-06) |
| 다국어 | UI 언어 토글만 (한국어 기본 + 영어 UI) | 글 본문은 원문 유지, 콘텐츠 i18n은 미래 확장 |
| 댓글 | giscus (GitHub Discussions) | 1단계 자리만, 2단계 실연동 |
| 조회수/공감 | Upstash Redis + Route Handlers | 1단계 UI 자리만, 2단계 실동작 |
| 포트폴리오 랜딩(3단계) | vercel.com/design 스타일 상단 네비 참고 | home/resources/people/career식 버튼 |

---

# 아키텍처 설계

## 확정 기술 스택

| 영역 | 선택 | 한 줄 근거 |
|---|---|---|
| 프레임워크 | Next.js 16 (App Router) | RSC 기반 완전 정적 프리렌더 → 읽기 전용 블로그에 최적, 3단계 포트폴리오까지 한 프레임워크로 확장 |
| 언어 | TypeScript | frontmatter/lib API 타입 계약으로 UI-데이터 분리를 컴파일 타임에 강제 |
| 패키지 매니저 | pnpm | 빠른 설치 + 엄격한 의존성 트리 |
| 마크다운 파이프라인 | `unified` + `remark-parse` + `remark-gfm` + `remark-rehype` → `rehype-slug` + `rehype-autolink-headings` + `rehype-pretty-code` + `rehype-stringify` | 순수 Markdown 전용(MDX 아님). 빌드타임 100% 정적, rehype 플러그인 체인으로 목차/하이라이팅 확장 |
| frontmatter 파싱 | `gray-matter` | `.md`의 YAML 메타 + 본문 분리. lib 내부 전용 |
| 코드 하이라이팅 | `rehype-pretty-code`(Shiki 엔진) | VS Code급 TextMate 문법, light/dark 듀얼 테마, 런타임 JS 0 |
| 목차(TOC) | `remark-flexible-toc` (또는 `github-slugger` 수동) | `rehype-slug`와 **동일한 github-slugger** 사용 → 앵커 id 일치 보장 |
| 스타일링/토큰 | CSS Modules + CSS 변수 토큰 레이어 | 컴포넌트는 `var(--…)`만 참조 → design.md 적용 = 값만 교체 |
| 폰트 | 공식 `geist` 패키지 (`geist/font/sans`·`geist/font/mono`) | self-host + preload, `--font-geist-sans/mono` CSS 변수 노출, 레이아웃 시프트 0 |
| i18n | 무의존성 커스텀 (타입드 사전 + `NEXT_LOCALE` 쿠키 + 네이티브 `Intl`) | UI 문자열만 토글, URL 로케일 세그먼트 불필요. 콘텐츠 i18n은 미래 확장 |
| 댓글 | `@giscus/react` (GitHub Discussions) | 1단계 자리만, 2단계 실연동 |
| 조회수/공감 | `@upstash/redis` + Route Handlers | 1단계 UI 자리+훅 계약만, 2단계 실동작 |
| 배포 | Vercel | Next.js 네이티브, 정적 프리렌더 + Route Handlers 무설정 |

> **버전 주의:** `rehype-pretty-code@0.14.4`는 `shiki@4.3.1`과 호환(peerDeps `^1||^2||^3||^4`). "shiki ^1만 지원"이라는 문서는 stale이니 무시. remark/rehype/shiki는 ESM 전용이라 **`next.config`는 반드시 `.mjs`/`.mts`/`.ts`** (`.js` 사용 시 `ERR_REQUIRE_ESM`). 파이프라인은 `processSync`가 아닌 async `process()`를 써야 함(Shiki 문법 로딩이 비동기).

## 프로젝트 구조

```
portfolio-blog/
├─ content/
│  ├─ posts/
│  │  └─ <slug>.md          # 글 1개 = 파일 1개. 파일명 = URL slug(영문 소문자+하이픈, 단일 진실원)
│  └─ categories.ts         # 카테고리 트리 단일 진실원: slug↔한글 라벨 매핑 + 정렬순서
│
├─ src/
│  ├─ app/
│  │  ├─ layout.tsx         # 루트 레이아웃. Geist 폰트 .variable 주입, locale 해석, <html lang>, globals.css import
│  │  ├─ globals.css        # tokens.css/themes.css import + reset + body 폰트/색 + 1200px 컬럼 유틸
│  │  ├─ page.tsx           # 홈(현재): 최신 글 목록 재사용. 3단계에 포트폴리오 랜딩으로 교체
│  │  ├─ blog/
│  │  │  ├─ page.tsx        # 전체 글 목록(최신순)
│  │  │  └─ [slug]/
│  │  │     ├─ page.tsx     # 글 읽기 RSC. generateStaticParams + dynamicParams=false. 본문/TOC/이전·다음
│  │  │     └─ prose.module.css  # 주입 HTML(제목·표·링크) + pretty-code data 속성 스타일(토큰 변수)
│  │  ├─ category/
│  │  │  ├─ [main]/page.tsx        # 대분류 목록
│  │  │  └─ [main]/[sub]/page.tsx  # 소분류 목록 (라우트 키는 항상 (main,sub) 쌍)
│  │  ├─ tag/[tag]/page.tsx (선택)  # 태그별 목록(카테고리와 독립 축)
│  │  └─ api/                       # 2단계에서 활성
│  │     ├─ views/[slug]/route.ts   # GET 읽기 / POST 증가(IP 해시 dedup)
│  │     └─ likes/[slug]/route.ts   # GET {count,liked} / POST 토글(SADD/SREM)
│  │
│  ├─ components/
│  │  ├─ PostList.tsx / PostCard.tsx    # 목록·카드(홈·blog·category 공유)
│  │  ├─ CategoryNav.tsx                # getCategoryTree() 소비 사이드바/네비
│  │  ├─ Toc.tsx                        # TocItem[] 소비 목차 nav(스크롤스파이는 선택 client 레이어)
│  │  ├─ PrevNext.tsx                   # 이전/다음 글
│  │  ├─ LanguageToggle.tsx             # setLocale 서버 액션 호출(client)
│  │  ├─ comments/Comments.tsx          # FEATURES.comments 분기(자리 placeholder ↔ giscus)
│  │  ├─ comments/GiscusComments.tsx    # @giscus/react(client). mapping=pathname strict=1 lang=ko
│  │  └─ reactions/PostReactions.tsx    # 조회수/공감 UI. usePostReactions(slug)만 소비, 단계 무관 불변
│  │
│  ├─ hooks/
│  │  └─ usePostReactions.ts   # 안정 데이터 계약. 1단계 no-op, 2단계 body만 교체
│  │
│  ├─ lib/
│  │  ├─ posts.ts              # fs·gray-matter·markdown 캡슐화 유일 데이터 접근 계층(UI는 이것만 import)
│  │  ├─ markdown.ts           # unified/remark/rehype가 사는 유일한 곳. 프로세서 싱글턴
│  │  ├─ categories.ts         # categories.ts 설정 읽어 트리/조회/라벨 해석/글 수 집계
│  │  ├─ toc.ts                # remark-flexible-toc 또는 github-slugger 워크 → TocItem[]
│  │  ├─ types.ts              # Post, PostMeta, PostFrontmatter, CategoryNode, TocItem 공용 타입
│  │  ├─ config.ts             # FEATURES 플래그(env 유도) — 1→2단계 스위치
│  │  └─ redis.ts              # 2단계: Redis.fromEnv() 싱글턴
│  │
│  ├─ styles/
│  │  ├─ tokens.css            # ★ 교체 레이어. :root Geist 토큰(색/간격/타입/radius/컴포넌트)
│  │  └─ themes.css            # [data-theme='dark'] 동일 변수명 오버라이드
│  │
│  └─ i18n/
│     ├─ config.ts             # locales ['ko','en'], Locale 타입, defaultLocale='ko'
│     ├─ ko.ts                 # 한국어 UI 문자열(진실원, type Dictionary = typeof ko)
│     ├─ en.ts                 # 영어 UI 문자열(: Dictionary → 누락 키 빌드 실패)
│     ├─ get-dictionary.ts     # 'server-only' 동적 import 맵 + getDictionary(locale)
│     ├─ get-locale.ts         # NEXT_LOCALE 쿠키 읽어 검증, 'ko' 폴백
│     ├─ actions.ts            # 'use server' setLocale(next) → 쿠키 set + revalidatePath
│     └─ format.ts             # 네이티브 Intl 래퍼 formatDate/formatNumber
│
├─ public/giscus/geist.css (선택, 2단계)  # Geist 토큰 매칭 커스텀 giscus 테마
├─ docs/PLAN.md                            # 이 문서
└─ next.config.mjs                         # ESM 전용 필수(.js 금지)
```

## 데이터 모델

### frontmatter 스키마

```markdown
---
title: "Next.js App Router로 블로그 만들기"   # 필수
date: "2026-07-06"                            # 필수. 반드시 따옴표(YAML Date 자동변환 → 타임존 하루밀림 방지)
category: "dev"                               # 필수. 대분류 slug(영문). 한글 라벨은 categories.ts에서 해석
subcategory: "web"                            # 선택. 소분류 slug(대분류 내에서만 유니크)
tags: ["nextjs", "markdown"]                  # 선택. 카테고리와 독립 축
description: "순수 마크다운 파이프라인 설계"    # 선택. 목록 카드·메타
draft: false                                  # 선택(기본 false). true면 공개 목록/라우트/이전·다음 전부 제외
---
```

- **slug는 frontmatter에 없음** — 파일명이 단일 진실원.
- **URL엔 영문 slug만** (`/category/dev/web`). 한글은 percent-encoding 회피 위해 절대 URL에 넣지 않음.
- **검증(zod 권장):** `subcategory`만 있고 `category` 없는 글 불가, `categories.ts`에 없는 slug는 유령 카테고리 → 빌드 조기 실패.
- lib 내부는 `categoryPath: string[]`로 정규화해 다뤄 3단계 계층/콘텐츠 i18n 승격에 대비.

### lib/posts 함수 시그니처 (UI를 파일시스템에서 분리)

```typescript
// 모두 async(Promise 반환) — 나중에 CMS(fetch)로 교체해도 시그니처 불변.
// React cache()로 감싸 빌드 중 중복 파싱 방지.

// --- lib/types.ts ---
export interface PostFrontmatter {
  title: string; date: string; category: string;
  subcategory?: string; tags?: string[]; description?: string; draft?: boolean;
}
export interface TocItem { depth: number; text: string; id: string }
export interface PostMeta {                    // 목록 카드용(본문 없음)
  slug: string; title: string; date: string;
  category: string; subcategory?: string;
  tags: string[]; description?: string; readingMinutes: number;
}
export interface Post extends PostMeta {        // 읽기 페이지 전체
  html: string; toc: TocItem[];
}
export type Adjacency = { prev: PostMeta | null; next: PostMeta | null };

// --- lib/posts.ts ---
export function listPosts(): Promise<PostMeta[]>;                    // draft 제외·최신순. 나머지가 재사용
export function getPostBySlug(slug: string): Promise<Post | null>;  // frontmatter+html+toc
export function getAllPostSlugs(): Promise<string[]>;               // generateStaticParams용
export function getPostsByCategory(
  main: string, sub?: string
): Promise<PostMeta[]>;
export function getPostsByTag(tag: string): Promise<PostMeta[]>;
export function getAdjacentPosts(                                   // 이전/다음: 범위 파라미터화
  slug: string, opts?: { within: 'all' | 'category' }
): Promise<Adjacency>;

// --- lib/markdown.ts (유일한 파이프라인 소재지) ---
export function renderMarkdown(raw: string): Promise<{ html: string; toc: TocItem[] }>;

// --- lib/categories.ts ---
export function getCategoryTree(): CategoryNode[];                  // 라벨·순서 해석
export function isValidCategory(main: string, sub?: string): boolean;
export function countPostsByCategory(main: string, sub?: string): Promise<number>;
```

## 디자인 토큰 레이어

**3계층 구조** — "나중에 진짜 design.md 적용 = 값만 교체".

**1) 폰트 (layout.tsx에서 1회 배선)**
```tsx
import { GeistSans } from 'geist/font/sans';
import { GeistMono } from 'geist/font/mono';
// <html lang={locale} className={`${GeistSans.variable} ${GeistMono.variable}`} suppressHydrationWarning>
```
`.variable`은 `--font-geist-sans`/`--font-geist-mono`를 **정의**(`.className`은 폰트 직접 적용이라 변수를 안 만듦 → 금지). self-host·preload로 레이아웃 시프트 0. Next 15+는 `transpilePackages` 불필요.
(참고: create-next-app 기본 템플릿은 `next/font/google`의 `Geist`를 쓰는데, 우리는 공식 `geist` 패키지 방식 — 둘을 동시에 배선하지 말 것.)

**2) 토큰 (`src/styles/tokens.css` — 유일한 교체 파일)**
```css
:root {
  /* 폰트: Geist 변수를 alias. Geist엔 한글 글리프 없음 → 한국어 폴백 필수 */
  --font-sans: var(--font-geist-sans), ui-sans-serif, system-ui,
               'Apple SD Gothic Neo', Pretendard, sans-serif;
  --font-mono: var(--font-geist-mono), ui-monospace, monospace;

  /* 색: sRGB hex 먼저, oklch 둘째(순서 중요 — 구엔진은 마지막 이해가능 선언 유지) */
  --color-primary: #171717;
  --color-background-100: #ffffff;
  --color-gray-100: #f2f2f2;   /* … gray-1000: #171717 */
  --color-blue-700: #006bff;
  --color-blue-700: oklch(57.61% 0.2508 258.23);

  /* 간격(4px 스케일) · radius · 컴포넌트 · 레이아웃 */
  --space-2: 8px; --space-4: 16px;
  --radius-sm: 6px; --radius-md: 12px; --radius-lg: 16px; --radius-full: 9999px;
  --button-height: 40px; --input-padding: 0 12px;
  --layout-max-width: 1200px;
}
```
컴포넌트 `.module.css`는 `var(--…)`만 참조. hex/px 하드코딩은 교체 약속을 깨는 lint-대상 냄새.

**3) 다크 테마 (`themes.css`)** — `[data-theme='dark'] { … }`가 **동일 변수명**을 다크 값으로 오버라이드. 컴포넌트는 테마를 전혀 모름, tokens 레이어만 앎.

> **와이드 개멋:** hex→oklch 이중 선언 캐스케이드로 ~95% 정확. 주요 브랜드 색만 `@media (color-gamut: p3) { @supports (color: oklch(0 0 0)) {…} }`로 게이팅. oklch는 Chrome/Edge 111+, Safari 15.4+, Firefox 113+ 지원.
> **적용 시:** design.md 토큰명은 하이픈 표기(`gray-alpha-100`, `heading-72`). 토큰명 매핑 규칙을 **하나로 통일**해 미래 find-replace가 자명하게. **적용 시점에 design.md를 다시 pull**해 값 드리프트 확인 (현재 시드값은 2026-07-06 기준 실측).

## UI 다국어(i18n)

**선택: 무의존성 커스텀 (UI 문자열 전용 토글).** `[lang]` URL 세그먼트를 강제하는 Next 내장 패턴이나 next-intl은 1단계 오버엔지니어링 — 3-파일 사전으로 "크롬 언어만 전환"이 목적.

- **문자열 위치:** `src/i18n/ko.ts`(진실원) / `en.ts`. `ko`를 `type Dictionary = typeof ko`로 삼고 `en: Dictionary`로 타입 → **영어 키 누락은 빌드 에러**. 네임스페이스로 그룹핑(`nav`, `post`, `common`).
- **로케일 해석(서버 우선):** 루트 레이아웃이 `NEXT_LOCALE` 쿠키를 읽어(`getLocale()`) `getDictionary(locale)`로 사전 로드, `<html lang={locale}>` 설정. 문자열은 서버 컴포넌트에 머물러 클라 번들에 안 들어감(Client가 필요한 슬라이스만 전달).
- **토글:** `'use server' setLocale(next)`가 쿠키 set 후 `revalidatePath('/', 'layout')`. 쿠키는 **Server Action/Route Handler에서만** write 가능(RSC의 `cookies()`는 async·read-only).
- **날짜/숫자:** 네이티브 `Intl.DateTimeFormat`/`NumberFormat`(`ko`/`en`), 폴리필 불필요. Vercel Node는 full-ICU.
- **주의:** localStorage-only 전환 금지(SSR 첫 페인트 불일치·하이드레이션 flash). 쿠키 읽기는 해당 라우트를 동적 렌더로 만듦(쿠키 개인화 블로그엔 허용).
- **콘텐츠 i18n(미래):** 글 본문은 원문 유지, i18n은 `lib/posts`를 건드리지 않음. 나중에 `lang` frontmatter 축 + next-intl(같은 `NEXT_LOCALE` 쿠키, 같은 `src/i18n/` 레이아웃)로 무손실 승격 가능. **계약: `NEXT_LOCALE` 쿠키명 고정** (next-intl v4·next-i18n-router 기본값과 일치).

## 구현 단계

### Stage 1 — 블로그 코어 (체크리스트) ✅ 2026-07-06 완료

- [x] `next.config.mjs`(ESM) + tsconfig, pnpm 프로젝트 세팅
- [x] `lib/markdown.ts` — unified 파이프라인 싱글턴(async `process()`), light/dark Shiki 듀얼 테마
- [x] `lib/posts.ts` — `listPosts`/`getPostBySlug`/`getPostsByCategory`/`getAdjacentPosts`(모두 async, `cache()` 래핑), draft 한 곳 필터
- [x] `content/categories.ts` + `lib/categories.ts` — slug↔라벨 트리, frontmatter 교차검증(유령 카테고리 빌드 실패), zod frontmatter 검증
- [x] `styles/tokens.css` + `themes.css` — Geist 구조 시드값 + **폰트는 StockPin 계승(Space Grotesk + Pretendard)**, layout.tsx 폰트 배선
- [x] `src/i18n/*` — ko/en 사전, 쿠키 로케일 해석, setLocale 액션, `LanguageToggle`
- [x] 라우트: `/blog/[slug]`(TOC·코드 하이라이팅·이전/다음), `/blog`, `/category/[main]`, `/category/[main]/[sub]` — 전부 `generateStaticParams` + `dynamicParams=false`, `params`는 `await`
- [x] `prose.module.css` — 주입 HTML + `rehype-pretty-code` data 속성 토큰 변수로 스타일, `overflow-x:auto`
- [x] 홈(`page.tsx`) = 최신 글 목록(3단계에 랜딩 교체 예정), `PostList`/`PostCard`/`CategoryNav` 공유 레이아웃
- [x] client-side 검색(제목/본문/태그) — `/search`, 본문은 마크다운 제거 평문 인덱스
- [x] **자리 확보(placeholder):**
  - [x] `components/comments/Comments.tsx` — `FEATURES.comments` false → 조용한 placeholder("댓글 기능이 곧 제공됩니다")
  - [x] `components/reactions/PostReactions.tsx` + `hooks/usePostReactions.ts` — 훅이 no-op 반환, **숫자 숨김**. 계약 시그니처(`enabled, views, likes, liked, like, loading`) 확정
  - [x] `lib/config.ts` — `FEATURES` 플래그(env 유도), `.env.example`에 Stage 2 변수 문서화

### Stage 2 — giscus 실연동 + 조회수/공감(Upstash) — 코드 완료 ✅, 계정 연동만 남음

**코드 (2026-07-06 완료):**
- [x] `GiscusComments.tsx` — `mapping="pathname"` + `strict="1"`, 로케일·테마 연동, `loading="lazy"`
- [x] **조회수:** `POST /api/views/[slug]` — `SHA-256(x-forwarded-for 첫 항목)` 해시 → `SET nx ex 86400`로 24h dedup 후 `INCR`. client `useEffect` 1회 POST(ref 가드)
- [x] **공감:** `POST /api/likes/[slug]` — localStorage UUID 기반 `SADD`/`SREM`, `SCARD` 카운트(멱등 토글), 낙관적 업데이트+실패 복구
- [x] `lib/redis.ts` `Redis.fromEnv()` 싱글턴, `UPSTASH_*`는 server-only
- [x] `usePostReactions` body 실구현 — `PostReactions.tsx`·읽기 페이지 무변경 (계약 유지 확인)
- [x] repo Discussions 활성화 (gh CLI로 완료)

**사용자 단계 — ✅ 완료 (2026-07-06, Pin이 진행):**
- [x] giscus 앱 설치 + "Comments" 카테고리 생성 + `NEXT_PUBLIC_GISCUS_*` 4개 Vercel env 설정 — 실서비스에서 댓글 입력 UI·GitHub 로그인 버튼 확인됨
- [x] Upstash Redis 생성 + `UPSTASH_REDIS_REST_URL/TOKEN` + `NEXT_PUBLIC_FEATURE_REACTIONS=true` Vercel env 설정 — 실서비스에서 조회수/공감 정상 동작 확인됨

**Stage 2 전체 완료.** 서버 렌더 검증: Stage 1 placeholder("댓글 기능이 곧 제공됩니다") 미노출 + 조회수 라벨 노출로 두 FEATURES 플래그 활성 확인.

### Stage 3 — 포트폴리오 / About / 프로젝트 + 랜딩 ✅ 2026-07-06 완료

- [x] 홈(`page.tsx`) → **포트폴리오 랜딩** (vercel.com/design식 히어로 + 필 버튼: 블로그/프로젝트/소개/GitHub + 최근 글 + 대표 프로젝트)
- [x] `/about` — `content/about.md` 마크다운 렌더 (내용은 이 파일만 수정)
- [x] `/projects` — `content/projects.ts` 데이터 기반 카드 그리드 (`featured: true`면 홈에도 노출)
- [x] 헤더 네비에 프로젝트/소개 추가 (ko/en 사전)
- [x] `design.md` 적용 완료 (2026-07-06) — https://vercel.com/design.md·design.dark.md에서 실측: 그레이 스케일(400/500/900 수정), 액센트 컬러(red-700→red-800로 정정, blue/red/green 실제 hex+oklch), 헤딩 스케일(전부 font-weight 600, heading-48 추가), 다크 테마(배경 #000000, 액센트 전량 실측). 폰트만 예외(StockPin 계승, 사용자 명시 요청)

### Stage 4 — 배포 ✅ 2026-07-06 완료 (커스텀 도메인만 남음)

- [x] Vercel 배포: `vercel` CLI로 로그인·프로젝트 생성·GitHub 레포 연결·프로덕션 배포 — **https://portfolio-blog-sepia-ten.vercel.app**
- [x] main 브랜치 push마다 자동 재배포 연결됨
- [x] `src/lib/config.ts`의 `SITE.url`을 실제 배포 URL로 교체
- [x] Stage 2 env 전체(giscus 4개 + Upstash 2개 + FEATURE_REACTIONS) Vercel Production에 설정, 재배포로 반영 확인
- [ ] **커스텀 도메인 — 제일 마지막에** (도메인 구입 후 Vercel 프로젝트 Settings > Domains에서 연결)

## 확장성 노트

- **데이터 접근은 `lib/posts`·`lib/categories`만 통과** — 모든 함수 async(Promise). CMS로 갈아끼워도 시그니처·화면 무변경. 마크다운 파이프라인은 `lib/markdown.ts` 한 곳에만.
- **색/폰트/간격은 CSS 변수로만** — 컴포넌트는 `var(--…)`만 참조. design.md 적용 = `tokens.css` 값만 교체. 다크 테마는 동일 변수명 오버라이드로 컴포넌트 격리.
- **카테고리 = frontmatter 영문 slug + `categories.ts` 라벨 해석** — 라벨/순서 변경은 설정 값만. lib 내부는 `categoryPath[]`로 정규화 → 3단계 계층/콘텐츠 i18n 승격 대비.
- **deferred 기능은 안정 계약 뒤에 숨김** — `usePostReactions` 훅 시그니처와 `Comments`/`PostReactions` 컴포넌트 경계를 1단계에 확정. 2단계는 훅 body + env 플래그만 변경, 읽기 페이지 불변.
- **UI 문자열은 `src/i18n` 한 모듈** — `NEXT_LOCALE` 쿠키명 고정으로 next-intl 콘텐츠 i18n 무손실 승격.
- **레이아웃 컴포넌트 공유** — 홈/blog/category, 미래 포트폴리오/About/projects가 `PostList`·`CategoryNav`·네비를 공유해 페이지가 늘어도 중복 없음.
- **TOC 앵커는 rehype-slug와 동일 slugger(github-slugger)** — 한글 제목·중복 제목에서도 `#앵커` 링크 정합성 보장(문서별 slugger 리셋).

---

## 결정 완료된 질문

- [x] 블로그 이름 → **Dotorian** (도토리안) — `src/lib/config.ts`의 `SITE.title`
- [x] 다크모드 토글 → 헤더 언어 토글 옆 ☾/☀ 버튼 (쿠키 + 서버 액션, FOUC 없음)
- [x] 커스텀 도메인 → 사용 예정, **제일 마지막 단계** (Stage 4 참고)
