diff --git a/.claude/skills/consolidate-memos/SKILL.md b/.claude/skills/consolidate-memos/SKILL.md index d8450b8..13a6aa9 100644 --- a/.claude/skills/consolidate-memos/SKILL.md +++ b/.claude/skills/consolidate-memos/SKILL.md @@ -29,7 +29,7 @@ description: 여러 메모 파일을 하나로 통합합니다. Use when the use ## 관계 재배선 (parent 정합성) -메모를 삭제하면 그 메모를 `parent`로 가리키던 다른 메모의 링크가 끊긴다. 삭제 **전에** 반드시 처리한다. 계보 스펙은 CLAUDE.md "메모 연결" 참고. +메모를 삭제하면 그 메모를 `parent`로 가리키던 다른 메모의 링크가 끊긴다. 삭제 **전에** 반드시 처리한다. 계보 스펙은 docs/memo-spec.md "메모 연결" 참고. ### 절차 (삭제 대상 각 메모 N에 대해) diff --git a/.claude/skills/create-memo/SKILL.md b/.claude/skills/create-memo/SKILL.md index 5894d19..cf946fc 100644 --- a/.claude/skills/create-memo/SKILL.md +++ b/.claude/skills/create-memo/SKILL.md @@ -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`). @@ -60,7 +60,7 @@ pnpm lint:memo # type 과 본문 형태가 맞는지 (빌드에도 ## 관계 자동화 (parent / relation) -새 메모가 기존 메모를 **이어가거나 대체**하면 frontmatter에 `parent`(+`relation`)를 추가한다. 계보 스펙은 CLAUDE.md "메모 연결" 참고. +새 메모가 기존 메모를 **이어가거나 대체**하면 frontmatter에 `parent`(+`relation`)를 추가한다. 계보 스펙은 docs/memo-spec.md "메모 연결" 참고. ### 절차 @@ -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 기준" 참고 - 태그가 애매하면 `[]`로 두는 것도 허용 diff --git a/CLAUDE.md b/CLAUDE.md index 53d67b7..bdea7d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 스타일) @@ -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 diff --git a/docs/memo-spec.md b/docs/memo-spec.md new file mode 100644 index 0000000..611d7d3 --- /dev/null +++ b/docs/memo-spec.md @@ -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 + + + +| 필드 | 필수 | 값 | 의미 | +|---|---|---|---| +| `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`와의 관계 종류 | + + + +> [!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가 아니라 문체 예외 표시에만 쓴다. diff --git a/scripts/lint-memo.mjs b/scripts/lint-memo.mjs index 8371268..81de381 100644 --- a/scripts/lint-memo.mjs +++ b/scripts/lint-memo.mjs @@ -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( + /([\s\S]*?)/ + ) + + 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 }) @@ -206,6 +274,8 @@ for (const file of files) { } } +checkSpecDrift() + const errors = findings.filter(({ level }) => level === 'error') const warnings = findings.filter(({ level }) => level === 'warn')