Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .agents/rules/git-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,26 @@ git push origin develop

그다음에 PR을 연다. PR 본문에는 squash를 쓰지 말아 달라고 적는다. 저장소의 PR 템플릿에 이미 들어 있다.

## 버전 번호는 사용자가 정한다

**릴리스 버전을 에이전트가 계산해서 올리지 않는다.** "배포해줘", "릴리스 진행해줘"를 받아도 `package.json`의 `version`과 `CHANGELOG.md`의 새 절, 태그 이름은 손대지 않는다. 사용자에게 번호를 묻고 답을 받은 뒤에 쓴다.

semver에 "기능을 더했으면 minor"라는 규칙이 있어서 번호가 계산으로 나온다고 착각하기 쉽다. 버전은 계산이 아니라 결정이다. 이번 변경 묶음을 무엇이라 부를지, 언제 끊을지는 저장소를 운영하는 사람이 정한다. 같은 변경이라도 다음 릴리스에 합칠 수도 있고 지금 끊을 수도 있다.

2026-08-30에 이 규칙을 어겼다. 배포 요청을 받고 1.3.1에서 1.4.0으로 스스로 올려 `CHANGELOG` 절까지 만들었다. 사용자가 중단시켰다.

## 근거가 있다는 것과 승인을 받았다는 것은 다르다

문서에 적혀 있다는 이유로, 규칙상 맞는다는 이유로, 되돌리기 쉽다는 이유로 확인을 건너뛰지 않는다. 그 셋은 전부 스스로에게 내주는 통과 도장이고 언제나 통과한다.

아래 셋 중 하나라도 걸리면 먼저 묻는다.

- 사용자가 화면에서 보게 될 동작이 바뀐다. 애니메이션과 레이아웃, 색이 여기 해당한다
- 저장소 밖으로 나가는 것이 바뀐다. 버전, 태그, 릴리스 노트, 배포
- 지우는 일이다. 파일과 기능, 설정 어느 것이든

설계 제안서와 확정된 명세를 가르는 방법이 하나 있다. "현재 값"과 "새 값"을 나란히 적은 표가 있으면 그것은 아직 반영되지 않은 제안이다. 이미 적용된 명세라면 "현재 값" 칸이 있을 이유가 없다.

## 금지 패턴

1. **`git add -A` 광범위 스테이징.** 파일 단위로 명시해서 add한다
Expand All @@ -101,3 +121,4 @@ git push origin develop
5. **"WIP" 커밋 그대로 병합.** 병합 전에 메시지를 정리한다
6. **강제 푸시.** 리베이스가 필요하면 `--force-with-lease`만 쓰고, 기본 브랜치에는 쓰지 않는다
7. **`contents` 서브모듈 임의 수정.** 별도 저장소이고 사용자 승인이 필요하다
8. **버전 번호 임의 상향.** `package.json`의 `version`, `CHANGELOG.md`의 새 절, 태그 이름은 사용자에게 묻고 쓴다
15 changes: 14 additions & 1 deletion .agents/rules/seo.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,23 @@ BlogPosting의 author와 About의 Person은 같은 `@id`(`{siteUrl}/about#person
`app/og/route.ts`는 `force-dynamic`이고 **런타임은 Node.js다.** runtime 지정이 없는 데다 폰트를 `node:fs`의 readFileSync로 `src/shared/assets/fonts`에서 읽는다. edge 런타임으로 바꾸면 폰트 로딩이 깨진다.

- 폰트는 Pretendard Regular(400)와 Bold(700) 2종이다. `src/shared/config/fonts.ts`가 모듈 로드 시점에 읽는다
- 1200x630 이미지를 만들고 title은 80자, tag는 32자에서 자른다
- 크기는 `OG_IMAGE_SIZE`(1200x630) 하나에서 나온다. 라우트와 `buildMetadata`, 기본 이미지가 같은 상수를 본다. 숫자를 다시 적지 않는다
- title은 80자, tag는 32자에서 자른다
- thumbnail 파라미터가 있으면 302로 원본에 넘긴다. 루트 상대 경로이거나 요청 호스트 또는 `siteMetadata.url` 호스트의 http(s) URL일 때만이다. 다른 호스트는 무시하고 기본 이미지를 그린다
- 응답에 `public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800`을 건다. 302로 넘기는 경우도 같은 헤더를 쓴다. `force-dynamic`이라 CDN이 캐시하지 않으면 SNS 크롤러가 올 때마다 Satori가 새로 그린다

### 배경과 기본 이미지는 디자인 자산이다

배경을 CSS 그라데이션으로 그리지 않는다. `src/shared/config/brand.ts`가 `og-background.jpg`를 base64로 읽어 Satori에 넘긴다. 디자인이 준 배경에는 광원과 질감이 들어 있어 `linear-gradient`로 재현되지 않는다.

홈은 Satori를 거치지 않고 `public/images/og-default.jpg`를 그대로 쓴다. 로고와 이름, 태그라인이 이미 그려진 완성 이미지다. `buildMetadata`에 `OG_DEFAULT_IMAGE`를 넘기면 url과 함께 치수도 나간다.

### 카드 안에 브랜드 표기는 하나다

카카오톡과 링크드인은 이미지 **아래에** 제목과 도메인을 자기가 다시 그린다. 그래서 이미지 안에 같은 것을 또 넣으면 한 카드에서 브랜드가 대여섯 번 반복된다. 2026-08-30 이전 구현이 그랬다. 위쪽 `chan9yu.dev`와 아래쪽 `chan9yu.dev`가 글자까지 같았고, 태그를 넘기면 위쪽 `#react`가 제목 `#react`와 겹쳤다.

지금 구성은 위쪽에 로고 심볼 하나(태그를 넘기면 `#태그`), 가운데 제목, 아래쪽 `chan9yu.dev`다. 요소를 더할 때 이미 있는 것과 같은 말을 하지 않는지 본다.

## frontmatter 게이트

모든 빌드의 prebuild가 `scripts/validate-seo.mjs`를 돌린다. 위반이 하나라도 있으면 빌드가 멈춘다.
Expand Down
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v24.19.0
v24.20.0
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const eslintConfig = defineConfig([
layerRule(["src/pages/**/*.{ts,tsx}"], ["app"]),
layerRule(["app/**/*.{ts,tsx}"], []),
prettierConfig,
globalIgnores([".next/**", "_workspace/**", "contents/**"])
globalIgnores([".next/**", "_workspace/**", "contents/**", "coverage/**"])
]);

export default eslintConfig;
30 changes: 15 additions & 15 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@
"license": "MIT",
"author": "chan9yu <dev.cgyeo@gmail.com>",
"private": true,
"packageManager": "pnpm@11.22.0",
"packageManager": "pnpm@11.24.0",
"engines": {
"node": ">=24",
"pnpm": ">=11"
"node": "24.x",
"pnpm": "11.x"
},
"scripts": {
"dev": "next dev --port 3100",
Expand All @@ -40,13 +40,13 @@
},
"dependencies": {
"@shikijs/rehype": "^4.4.3",
"@upstash/redis": "^1.38.2",
"@upstash/redis": "^1.38.3",
"@vercel/analytics": "^2.0.1",
"@vercel/speed-insights": "^2.0.0",
"clsx": "^2.1.1",
"fuse.js": "^7.5.0",
"lucide-react": "^1.32.0",
"next": "^16.3.1",
"lucide-react": "^1.37.0",
"next": "^16.3.3",
"next-mdx-remote": "^6.0.0",
"next-themes": "^0.4.6",
"react": "^19.2.8",
Expand All @@ -58,31 +58,31 @@
"tailwind-merge": "^3.6.0",
"vfile": "^6.0.3",
"vfile-matter": "^5.0.1",
"zod": "^4.4.3"
"zod": "^4.5.2"
},
"devDependencies": {
"@feature-sliced/steiger-plugin": "0.7.0",
"@feature-sliced/steiger-plugin": "^0.7.0",
"@playwright/test": "^1.62.1",
"@svgr/webpack": "^8.1.0",
"@tailwindcss/postcss": "^4.3.3",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.5",
"@types/node": "^26.2.0",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.6",
"@types/node": "^26.4.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@types/react-dom": "^19.2.5",
"@vitest/coverage-v8": "^4.1.11",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.39.5",
"eslint-config-next": "^16.3.1",
"eslint-config-next": "^16.3.3",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-simple-import-sort": "^14.0.0",
"jsdom": "^30.0.1",
"lefthook": "^2.1.10",
"lefthook": "^2.1.12",
"msw": "^2.15.0",
"prettier": "^3.9.6",
"prettier-plugin-tailwindcss": "^0.8.1",
"steiger": "0.6.0",
"steiger": "^0.6.0",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"vitest": "^4.1.11"
Expand Down
Loading
Loading