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
2 changes: 1 addition & 1 deletion .claude/skills/consolidate-memos/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ description: 여러 메모 파일을 하나로 통합합니다. Use when the use

## 관계 재배선 (parent 정합성)

메모를 삭제하면 그 메모를 `parent`로 가리키던 다른 메모의 링크가 끊긴다. 삭제 **전에** 반드시 처리한다. 계보 스펙은 CLAUDE.md "메모 연결" 참고.
메모를 삭제하면 그 메모를 `parent`로 가리키던 다른 메모의 링크가 끊긴다. 삭제 **전에** 반드시 처리한다. 계보 스펙은 docs/memo-spec.md "메모 연결" 참고.

### 절차 (삭제 대상 각 메모 N에 대해)

Expand Down
6 changes: 3 additions & 3 deletions .claude/skills/create-memo/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ node scripts/create-memo.mjs note # 산문이 주인공 (기본값)
- `src/content/memo/` 내 가장 큰 숫자 ID + 1
- `ctime`, `mtime`을 오늘 날짜로 설정
- 빈 `tags: []`
- `status`: `bookmarks`는 `archive`, 나머지는 `draft` (Status 기준은 CLAUDE.md 참고)
- `status`: `bookmarks`는 `archive`, 나머지는 `draft` (Status 기준은 docs/memo-spec.md 참고)

`type`과 본문 형태가 어긋나면 빌드가 잡는다 (`pnpm lint:memo`).

Expand Down Expand Up @@ -60,7 +60,7 @@ pnpm lint:memo # type 과 본문 형태가 맞는지 (빌드에도

## 관계 자동화 (parent / relation)

새 메모가 기존 메모를 **이어가거나 대체**하면 frontmatter에 `parent`(+`relation`)를 추가한다. 계보 스펙은 CLAUDE.md "메모 연결" 참고.
새 메모가 기존 메모를 **이어가거나 대체**하면 frontmatter에 `parent`(+`relation`)를 추가한다. 계보 스펙은 docs/memo-spec.md "메모 연결" 참고.

### 절차

Expand Down Expand Up @@ -105,5 +105,5 @@ pnpm lint:memo # type 과 본문 형태가 맞는지 (빌드에도
## 주의사항

- 주제가 명확하지 않으면 빈 파일만 생성하고 사용자에게 작성을 위임
- `status`: 작성 중이면 `draft`, 저장만 해두면 `archive`, 완성되면 `release`. **`bookmarks`는 `draft`를 쓰지 않는다** (링크는 붙여넣은 순간이 최종형) — 기준은 CLAUDE.md "Status 기준" 참고
- `status`: 작성 중이면 `draft`, 저장만 해두면 `archive`, 완성되면 `release`. **`bookmarks`는 `draft`를 쓰지 않는다** (링크는 붙여넣은 순간이 최종형) — 기준은 docs/memo-spec.md "Status 기준" 참고
- 태그가 애매하면 `[]`로 두는 것도 허용
105 changes: 11 additions & 94 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,108 +57,24 @@

## 메모 명세

### 파일 구조
**명세는 [docs/memo-spec.md](docs/memo-spec.md)에 있다** — 필드 목록, `type`·`status` 기준, 태그·각주 규칙, 검사 체계, 채택하지 않은 필드와 근거.

- **위치**: `src/content/memo/`
- **파일명**: 숫자 기반 ID (1.md, 2.md, ...)
- **확장자**:
- `.md` - 기본
- `.mdx` - 컴포넌트 임포트가 필요한 경우만

### Frontmatter (필수)
여기서는 쓸 때 바로 필요한 것만 둔다.

```yaml
---
type: note # 필수, 'bookmarks' | 'snippet' | 'note'
tags: ['tag1', 'tag2'] # 필수, 빈 배열 [] 허용
type: note # 필수, 'bookmarks' | 'snippet' | 'note' (형태)
tags: ['tag1', 'tag2'] # 필수, 빈 배열 [] 허용 (주제)
status: draft # 필수, 'draft' | 'archive' | 'release'
ctime: YYYY-MM-DD # 필수, 생성일
mtime: YYYY-MM-DD # 필수, 수정일
title: 제목 # 선택
description: 설명 # 선택
voice: author # 선택, 이 메모의 존댓말이 저자 본인의 것일 때 (lint:voice 제외)
parent: '307' # 선택, 이 메모가 이어지는/대체하는 부모 메모 ID
relation: continues # 선택, 'continues'(기본) 또는 'supersedes'
ctime: YYYY-MM-DD # 필수
mtime: YYYY-MM-DD # 필수
---
```

### 메모 연결 (계보)

메모 간 관계는 세 레이어로 표면화됨:

| 레이어 | 필드 | 의미 |
|---|---|---|
| 자동 유사도 | (없음) | 공유 태그 IDF 가중 → "관련 메모" 자동 노출 (사이트) |
| 방향 계보 | `parent` | 이 메모가 어떤 메모에서 이어졌는지 (시간/계보) |
| 관계 종류 | `relation` | `continues`(이어짐) / `supersedes`(부모를 대체) |

- **thread**: `parent` 체인을 따라가면 도출됨
- **branch**: 같은 `parent`를 가진 메모가 2개 이상 = 자동 분기 (별도 표기 불필요)
- **supersedes**: `relation: supersedes`면 부모 페이지에 "대체됨" 경고 표시
- `parent`는 `release` 메모만 해석됨 (draft·archive는 공개되지 않음)
- **후보 탐색은 `node scripts/find-related.mjs <메모ID>`** — 공유 태그와 **공유 URL** 두 신호를 본다. 같은 글을 가리키는 메모쌍 29개 중 14쌍은 공유 태그가 0개라 태그만으로는 만나지 못한다. `--all`은 비공개까지 보여주는데 그건 계보가 아니라 통합 후보다 → 577.md

### Type 기준

`type`은 **형태**만 담는다. **주제는 `tags`가 담당** — 둘을 섞지 않는다.

| 값 | 의미 | 판별 |
|---|---|---|
| `bookmarks` | 링크 모음이 주인공 | 코드 없음 + 불릿이 대부분 링크 |
| `snippet` | 코드가 주인공 | 코드 블록 있음 + 산문은 그걸 설명하는 정도 |
| `note` | 산문이 주인공 | 나머지 (기술 문서·비교·아이디어·디버그 전부) |

- **`type`과 본문이 어긋나면 빌드가 잡는다** — `pnpm lint:memo` (빌드에 연결됨). 논리적 모순(`snippet`인데 코드 없음 등)은 오류, 휴리스틱 불일치는 경고. `draft`는 경고까지만.
- `debug`, `comparison` 같은 **목적/주제는 `type`이 아니라 `tags`에 넣는다.** 형태를 태그에 넣으면 태그가 두 일을 하게 되고 유사도 계산에 예외가 생긴다 (이전 `bookmarks` 태그가 그랬다 → 577.md).
- 아래 유형 템플릿(A~F)은 **본문을 어떻게 쓸지에 대한 가이드**이고, `type`과 1:1이 아니다. C·D·E·F는 모두 `type: note`다.

### Status 기준

| 값 | 의미 |
|---|---|
| `draft` | 작성 중, 비공개 (완성 예정) |
| `archive` | 저장/보관, 비공개 (완성 예정 없음) |
| `release` | 완성됨, 공개 가능 |

- **`bookmarks`는 `draft`를 쓰지 않는다.** 링크는 붙여넣은 순간이 최종형이라 "작성 중"이 없다 → `archive`(보관) 아니면 `release`(공개).
- `draft`를 "공개 안 함"으로 쓰면 안 된다. 그러면 작성 중 큐가 방치된 메모로 막힌다 (231개가 그랬다 → 577.md).
- 비공개 목록은 로컬에서만 보인다: `/memos/draft`, `/memos/archive` (프로덕션에서는 비어 있음)

### 태그 규칙

- **네이밍**: kebab-case
- O: `design-system`, `react-query`, `google-apps-script`
- X: `design_system`, `reactQuery`
- **빈 태그**: `[]` 허용 (분류가 애매한 경우)
- **형태는 태그가 아니다**: 링크 모음/코드 스니펫 같은 형태는 `type`으로 표기한다 (과거 `bookmarks` 태그는 `type: bookmarks`로 이동)

### 각주 규칙

메모 번호를 접두사로 사용:
```markdown
[^496-1]: 첫 번째 각주
[^496-2]: 두 번째 각주
```

- **참조와 정의는 짝이 맞아야 한다** — `pnpm lint:memo`가 잡는다. 참조만 있으면 마커가 깨진 채 렌더되고, 정의만 있으면 렌더되지 않는다 (7개 파일이 그랬다).
- **각주도 압축 대상이다.** 링크 제목에 이미 있는 내용을 늘려 쓰지 않는다 (83.md는 각주 하나가 5문장이었다).

### 링크 부식

메모의 진짜 부식은 링크다. `mtime`은 신선도가 아니다 — 542개 중 288개가 일괄 수정 자국이고 `ctime`도 182개가 마이그레이션 시점이다. 그래서 신선도를 날짜로 선언하지 않고 링크로 판정한다 (→ 577.md).

```bash
pnpm lint:links # 전체 (URL 1776개, 호스트 793개)
pnpm lint:links --release # 공개 메모만
pnpm lint:links 229 # 특정 메모만
```

- **빌드에 연결하지 않는다** — 네트워크에 의존하고 느리며, 남의 서버 상태로 내 빌드가 깨지면 안 된다.
- 죽었다고 단정하는 건 **404·410·DNS 실패**뿐이다. 403·405·429·타임아웃은 봇 차단이 흔하므로 "확인 불가"로 따로 센다.

### 본문 구조

자유 형식. 유형에 따라 적절히 선택.
- 선택 필드(`title`·`description`·`embed`·`voice`·`parent`·`relation`)는 명세 참고
- **`type`은 형태, `tags`는 주제.** 둘을 섞지 않는다
- **`bookmarks`는 `draft`를 쓰지 않는다** — 링크는 붙여넣은 순간이 최종형이다
- 어긋나면 빌드가 잡는다: `pnpm lint:memo` (형태·각주·명세↔스키마), `pnpm lint:voice` (남의 문장)

### Alerts (GitHub 스타일)

Expand Down Expand Up @@ -374,6 +290,7 @@ mtime: YYYY-MM-DD

## 변경 이력

- 명세를 `docs/memo-spec.md`로 분리. CLAUDE.md는 판단(작성 원칙·압축·템플릿)만 남기고 명세를 가리킨다 (389 → 305줄). 같은 규칙이 스키마·린터·문서·스킬 네 곳에 흩어져 있어서, 최소한 **명세와 스키마가 어긋나는 건 `lint:memo`가 잡는다** — 실제로 `embed`가 스키마에만 있고 문서에 없었다
- 각주 짝 정리: 7개 파일 (정의 없는 참조 3 / 참조 없는 정의 4). 정의가 남아 있던 3개는 번호 순서가 위치를 알려줘서 마커를 복원했고, 붙을 곳이 없던 1개는 삭제. `lint:memo`에 규칙 추가
- 압축 원칙 추가: 존댓말을 "남의 문장" 신호로 사용. 검출 29개 → 0개 (공개 23개 → 0개). `lint:voice`를 빌드에 연결(`release`만 실패)하고, 저자 본인 문체는 `voice: author`로 표시 → 577.md
- 산문 삭제/압축: 268·272·274·46·173·21·56·243·307·149·329·247·271·340·23
Expand Down
137 changes: 137 additions & 0 deletions docs/memo-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# 메모 명세

메모 파일의 **형식**을 정의한다. 필드가 무엇이고 어떤 값을 갖는지, 무엇을 기계가 검사하고 무엇을 사람이 판단하는지.

- **어떻게 쓸지**(작성 원칙·압축·템플릿)는 [CLAUDE.md](../CLAUDE.md)
- **왜 이렇게 정했는지**는 [577.md](../src/content/memo/577.md)

이 문서와 `src/content.config.ts`가 어긋나면 `pnpm lint:memo`가 잡는다.

---

## 파일 구조

- **위치**: `src/content/memo/`
- **파일명**: 숫자 ID (`1.md`, `2.md`, …). ID가 곧 URL(`/memo/1`)
- **확장자**: `.md` 기본. `.mdx`는 **컴포넌트 임포트가 필요할 때만** — 임포트를 걷어냈으면 `.md`로 되돌린다

## Frontmatter

<!-- fields:start -->

| 필드 | 필수 | 값 | 의미 |
|---|---|---|---|
| `type` | 필수 | `bookmarks` \| `snippet` \| `note` | 형태 (아래 "type" 참고) |
| `title` | 선택 | 문자열 | 제목. 없으면 본문 첫 줄이 발췌로 쓰인다 |
| `description` | 선택 | 문자열 | 설명 (메타 태그) |
| `tags` | 필수 | 문자열 배열 (`[]` 허용) | 주제 |
| `status` | 필수 | `draft` \| `archive` \| `release` | 공개 상태 |
| `ctime` | 필수 | `YYYY-MM-DD` | 생성일 |
| `mtime` | 필수 | `YYYY-MM-DD` | 수정일 |
| `embed` | 선택 | URL | 실행 가능한 예제 링크 (CodePen·StackBlitz 등) |
| `voice` | 선택 | `author` | 이 메모의 존댓말이 저자 본인의 것 (`lint:voice` 제외) |
| `parent` | 선택 | 메모 ID 문자열 | 이 메모가 이어지는/대체하는 부모 |
| `relation` | 선택 | `continues`(기본) \| `supersedes` | `parent`와의 관계 종류 |

<!-- fields:end -->

> [!NOTE]
> 위 표는 `src/content.config.ts`의 `memo` 스키마와 이름·필수 여부가 일치해야 한다. 마커(`fields:start`/`fields:end`) 안이 검사 대상이다.

## type — 형태

**형태만 담는다. 주제는 `tags`가 담당한다.** 둘을 섞으면 태그가 두 일을 하게 되고 유사도 계산에 예외가 생긴다.

| 값 | 의미 | 판별 |
|---|---|---|
| `bookmarks` | 링크 모음이 주인공 | 코드 없음 + 불릿이 대부분 링크 |
| `snippet` | 코드가 주인공 | 코드 블록 있음 + 산문은 그걸 설명하는 정도 |
| `note` | 산문이 주인공 | 나머지 (기술 문서·비교·아이디어·디버그 전부) |

- `debug`·`comparison` 같은 **목적/주제는 `type`이 아니라 `tags`**에 넣는다.
- ` ```md ` 처럼 산문을 인용한 펜스는 코드로 세지 않는다.
- **압축하면 형태가 바뀔 수 있다.** 부풀린 산문을 걷어내면 남는 게 링크뿐일 수 있다.

## status — 공개 상태

| 값 | 의미 | 공개 |
|---|---|---|
| `draft` | 작성 중 (완성 예정) | 비공개 |
| `archive` | 저장/보관 (완성 예정 없음) | 비공개 |
| `release` | 완성됨 | 공개 |

- **`bookmarks`는 `draft`를 쓰지 않는다.** 링크는 붙여넣은 순간이 최종형이라 "작성 중"이 없다 → `archive` 아니면 `release`.
- `draft`를 "공개 안 함"으로 쓰면 작성 중 큐가 방치된 메모로 막힌다.
- 비공개 목록은 로컬에서만 보인다: `/memos/draft`, `/memos/archive`. 프로덕션에서는 비어 있다.
- 공개 경로(상세·태그·`/type`·RSS·사이트맵·검색)는 모두 `release`만 포함한다.

## tags — 주제

- **kebab-case**: `design-system` (O) / `design_system`, `reactQuery` (X)
- 빈 배열 `[]` 허용
- **형태는 태그가 아니다** — 링크 모음·코드 스니펫 같은 형태는 `type`으로 표기한다

## 각주

메모 번호를 접두사로 쓴다.

```markdown
- [링크](URL)[^496-1]

[^496-1]: 설명
```

- **참조와 정의는 짝이 맞아야 한다.** 참조만 있으면 마커가 깨진 채 렌더되고, 정의만 있으면 렌더되지 않는다.
- **각주도 압축 대상이다.** 링크 제목에 이미 있는 내용을 늘려 쓰지 않는다.

## 메모 연결 (계보)

| 레이어 | 필드 | 의미 |
|---|---|---|
| 자동 유사도 | (없음) | 공유 태그 IDF + **공유 URL** → "관련 메모" |
| 방향 계보 | `parent` | 이 메모가 어떤 메모에서 이어졌는지 |
| 관계 종류 | `relation` | `continues` / `supersedes` |

- **thread**: `parent` 체인을 따라가면 도출된다
- **branch**: 같은 `parent`를 가진 메모가 2개 이상이면 자동 분기 (별도 표기 불필요)
- `parent`는 `release` 메모만 해석된다 — `draft`·`archive`를 부모로 걸지 않는다
- 후보 탐색: `node scripts/find-related.mjs <메모ID>` (`--all`은 비공개까지 = 통합 후보)

## 링크 부식

메모의 진짜 부식은 링크다. **`mtime`은 신선도가 아니다** — 일괄 수정 자국이 많고 `ctime`도 마이그레이션 시점이 섞여 있다. 그래서 신선도를 날짜로 선언하지 않고 링크로 판정한다.

- 죽었다고 단정하는 건 **404·410·DNS 실패**뿐이다. 403·405·429·타임아웃은 봇 차단이 흔하므로 "확인 불가"로 따로 센다.
- 네트워크에 의존하므로 **빌드에 연결하지 않는다.**

## 검사 체계

**기계가 잡는 것과 사람이 판단하는 것을 구분한다.** 린터가 조용한 게 곧 문제가 없다는 뜻은 아니다.

| 검사 | 대상 | 빌드 | 실패 조건 |
|---|---|---|---|
| `lint:memo` | `type` ↔ 본문 형태, 각주 참조 ↔ 정의, 이 문서 ↔ 스키마 | **게이트** | 논리적 모순은 오류, 휴리스틱 불일치는 경고. `draft`는 경고까지만 |
| `lint:voice` | 인용 표시 없는 존댓말 (남의 문장) | **게이트** | `release`에서 검출되면 실패. `draft`·`archive`는 경고 |
| `lint:links` | 죽은 링크 | 미연결 | 수동 실행 |

### 사람만 판단할 수 있는 것

| 무엇 | 왜 기계가 못 하나 | 표시 |
|---|---|---|
| 저자가 존댓말로 쓴 자기 글 | 번역문과 같은 신호로 잡힌다 | `voice: author` |
| 과압축·자기 반복 | 부풀린 문장은 잡지만 눌러 담은 문장은 못 잡는다 | (없음) |
| 계보인가 단순 유사인가 | URL이 겹쳐도 계보가 아닐 수 있다 | `parent`를 걸지 않음 |
| 링크가 "확인 불가"일 때 | 봇 차단과 실제 소멸을 구분 못 한다 | 브라우저로 확인 |

## 채택하지 않은 것

OKF(Open Knowledge Format)를 대조하면서 검토했으나 이 코퍼스에는 붙일 표면이 없어 도입하지 않았다. 같은 논의를 반복하지 않기 위해 근거를 남긴다.

| 필드 | 안 쓰는 이유 |
|---|---|
| `stale_after` | 버전을 고정한 메모가 10개뿐이다. 만료를 선언할 대상이 사실상 없고, 시간축도 오염돼 있다(일괄값) |
| `resource` | URL이 2개 이상인 메모가 359개다. "이 메모의 자원"을 하나로 특정할 수 없다 |
| `verified` / 신뢰 등급 | 단일 저자라 전부 같은 값이 된다 |
| 관계 레이어 추가 | `parent`조차 오래 0이었다. 스키마가 부족한 게 아니라 후보를 못 보고 있었다 |

`generated.by`(사람/기계)는 개념만 가져와 `voice: author`로 좁혀 썼다 — 전체 provenance가 아니라 문체 예외 표시에만 쓴다.
70 changes: 70 additions & 0 deletions scripts/lint-memo.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,74 @@ const classify = (a) => {
const field = (frontmatter, key) =>
(frontmatter.match(new RegExp(`^${key}: (.*)$`, 'm')) ?? [])[1]?.trim()

/**
* docs/memo-spec.md 의 필드 표와 content.config.ts 의 스키마를 대조한다.
*
* 같은 규칙이 스키마·린터·문서·스킬 네 곳에 흩어져 있어서, 필드를 하나
* 추가할 때마다 네 군데를 고쳐야 했다. 최소한 문서와 스키마가 어긋나는
* 것은 기계가 잡는다 (실제로 `embed` 가 스키마에만 있고 문서에 없었다).
*/
const checkSpecDrift = () => {
const specPath = 'docs/memo-spec.md'
const configPath = 'src/content.config.ts'

if (!fs.existsSync(specPath) || !fs.existsSync(configPath)) {
return
}

const spec = fs.readFileSync(specPath, 'utf8')
const table = spec.match(
/<!-- fields:start -->([\s\S]*?)<!-- fields:end -->/
)

if (!table) {
add('error', specPath, '필드 표 마커(fields:start/end)를 찾을 수 없다')
return
}

const documented = new Map(
[...table[1].matchAll(/^\|\s*`(\w+)`\s*\|\s*(필수|선택)\s*\|/gm)].map(
([, name, required]) => [name, required === '필수']
)
)

const config = fs.readFileSync(configPath, 'utf8')
const schema = config.match(
/const memo = defineCollection\(\{[\s\S]*?schema: z\.object\(\{([\s\S]*?)\n {2}\}\)/
)

if (!schema) {
add('error', configPath, 'memo 스키마를 파싱할 수 없다')
return
}

const actual = new Map(
[...schema[1].matchAll(/^ {4}(\w+):\s*(.+)$/gm)].map(([, name, value]) => [
name,
!/\.optional\(\)|\.default\(/.test(value),
])
)

for (const [name, required] of actual) {
if (!documented.has(name)) {
add('error', specPath, `스키마에 있는 \`${name}\` 이 필드 표에 없다`)
} else if (documented.get(name) !== required) {
add(
'error',
specPath,
`\`${name}\` 의 필수 여부가 스키마와 다르다 ` +
`(문서: ${documented.get(name) ? '필수' : '선택'}, 스키마: ${required ? '필수' : '선택'})`
)
}
}

for (const name of documented.keys()) {
if (!actual.has(name)) {
add('error', specPath, `필드 표의 \`${name}\` 이 스키마에 없다`)
}
}
}

const findings = []
const add = (level, file, message) => findings.push({ level, file, message })

Expand Down Expand Up @@ -206,6 +274,8 @@ for (const file of files) {
}
}

checkSpecDrift()

const errors = findings.filter(({ level }) => level === 'error')
const warnings = findings.filter(({ level }) => level === 'warn')

Expand Down
Loading