Skip to content

Commit 0f3d806

Browse files
committed
refactor(blog): 발행 정책 중앙화
공개 Tech 글의 리뷰 하한과 featured 자격을 policy 모듈의 실행 기준으로 통합했습니다. 글별 큐레이션 데이터는 meta.json에 유지하고 ADR로 경계를 기록했습니다.
1 parent 943e43b commit 0f3d806

8 files changed

Lines changed: 168 additions & 63 deletions

File tree

Lines changed: 48 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
// @vitest-environment node
22

33
import { describe, expect, it } from 'vitest';
4-
import type { FeedData, QualityReview } from '@/blog/model/types';
4+
import type { FeedData } from '@/blog/model/types';
55
import { getAllFeedSlugs, getSortedFeedData } from './post-repository';
6-
import { filterVisiblePosts, isPostVisible } from './publication-policy';
6+
import {
7+
filterVisiblePosts,
8+
getCoreTechReviewAverage,
9+
isEligibleForFeaturedPost,
10+
isPostVisible,
11+
meetsPublicTechReviewThreshold,
12+
PUBLICATION_POLICY,
13+
} from './policy';
714

815
const FEATURED_SLUGS = [
916
'ctr-pipeline',
@@ -12,17 +19,6 @@ const FEATURED_SLUGS = [
1219
'msa-domain-workspace-submodule',
1320
];
1421

15-
function readCoreAverage(review: QualityReview | undefined): number | null {
16-
const scores = [review?.philosophy, review?.design, review?.implementation];
17-
18-
if (scores.some((score) => typeof score !== 'number')) {
19-
return null;
20-
}
21-
22-
const [philosophy, design, implementation] = scores as number[];
23-
return (philosophy + design + implementation) / 3;
24-
}
25-
2622
function describePost(post: FeedData): string {
2723
return `${post.slug} (${post.title})`;
2824
}
@@ -40,6 +36,34 @@ describe('publication policy', () => {
4036
expect(filterVisiblePosts([privatePost, publicPost])).toEqual([publicPost]);
4137
});
4238

39+
it('keeps editorial thresholds in the policy module', () => {
40+
expect(
41+
meetsPublicTechReviewThreshold({
42+
philosophy: 3.5,
43+
design: 3.5,
44+
implementation: 3.5,
45+
})
46+
).toBe(true);
47+
expect(meetsPublicTechReviewThreshold({})).toBe(false);
48+
49+
expect(
50+
isEligibleForFeaturedPost({
51+
category: 'Tech',
52+
qualityReview: {
53+
brandFit: PUBLICATION_POLICY.featured.minimumBrandFit,
54+
},
55+
})
56+
).toBe(true);
57+
expect(
58+
isEligibleForFeaturedPost({
59+
category: 'Life',
60+
qualityReview: {
61+
brandFit: PUBLICATION_POLICY.featured.minimumBrandFit,
62+
},
63+
})
64+
).toBe(false);
65+
});
66+
4367
it('keeps every private post out of public listings and static paths', () => {
4468
const allPosts = getSortedFeedData({ includePrivate: true });
4569
const privatePosts = allPosts.filter(
@@ -70,17 +94,17 @@ describe('publication policy', () => {
7094
const offenses = getSortedFeedData()
7195
.filter((post) => post.category === 'Tech')
7296
.flatMap((post) => {
73-
const average = readCoreAverage(post.qualityReview);
97+
const average = getCoreTechReviewAverage(post.qualityReview);
7498

75-
if (average === null) {
76-
return [
77-
`${describePost(post)}: qualityReview core scores are incomplete`,
78-
];
79-
}
99+
if (!meetsPublicTechReviewThreshold(post.qualityReview)) {
100+
if (average === null) {
101+
return [
102+
`${describePost(post)}: qualityReview core scores are incomplete`,
103+
];
104+
}
80105

81-
if (average <= 3) {
82106
return [
83-
`${describePost(post)}: core average ${average.toFixed(2)} <= 3.0`,
107+
`${describePost(post)}: core average ${average.toFixed(2)} <= ${PUBLICATION_POLICY.publicTech.minimumCoreReviewAverageExclusive.toFixed(1)}`,
84108
];
85109
}
86110

@@ -93,26 +117,9 @@ describe('publication policy', () => {
93117
it('requires featured posts to meet branding thresholds', () => {
94118
const featuredPosts = getSortedFeedData().filter((post) => post.featured);
95119
const offenses = featuredPosts.flatMap((post) => {
96-
const brandFit = post.qualityReview?.brandFit;
97-
const currentOffenses: string[] = [];
98-
99-
if (post.category !== 'Tech') {
100-
currentOffenses.push(
101-
`${describePost(post)}: featured posts must be Tech`
102-
);
103-
}
104-
105-
if (post.series) {
106-
currentOffenses.push(
107-
`${describePost(post)}: featured posts must not be series`
108-
);
109-
}
110-
111-
if (typeof brandFit !== 'number' || brandFit < 4) {
112-
currentOffenses.push(`${describePost(post)}: brandFit must be >= 4.0`);
113-
}
114-
115-
return currentOffenses;
120+
return isEligibleForFeaturedPost(post)
121+
? []
122+
: [`${describePost(post)}: does not meet featured criteria`];
116123
});
117124

118125
expect(featuredPosts.map((post) => post.slug).sort()).toEqual(

blog/services/policy.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import type { FeedData, QualityReview } from '@/blog/model/types';
2+
3+
export const PUBLICATION_POLICY = {
4+
publicTech: {
5+
minimumCoreReviewAverageExclusive: 3,
6+
},
7+
featured: {
8+
category: 'Tech',
9+
minimumBrandFit: 4,
10+
},
11+
} as const;
12+
13+
const CORE_TECH_REVIEW_FIELDS = [
14+
'philosophy',
15+
'design',
16+
'implementation',
17+
] as const;
18+
19+
export interface PublicationQueryOptions {
20+
includePrivate?: boolean;
21+
}
22+
23+
export function isPostVisible(
24+
post: Pick<FeedData, 'visibility'>,
25+
options: PublicationQueryOptions = {}
26+
): boolean {
27+
return options.includePrivate || post.visibility === 'public';
28+
}
29+
30+
export function filterVisiblePosts<T extends Pick<FeedData, 'visibility'>>(
31+
posts: T[],
32+
options: PublicationQueryOptions = {}
33+
): T[] {
34+
return posts.filter((post) => isPostVisible(post, options));
35+
}
36+
37+
export function getCoreTechReviewAverage(
38+
review: QualityReview | undefined
39+
): number | null {
40+
const scores = CORE_TECH_REVIEW_FIELDS.map((field) => review?.[field]);
41+
const numericScores = scores.filter(
42+
(score): score is number => typeof score === 'number'
43+
);
44+
45+
if (numericScores.length !== CORE_TECH_REVIEW_FIELDS.length) {
46+
return null;
47+
}
48+
49+
return (
50+
numericScores.reduce((sum, score) => sum + score, 0) / numericScores.length
51+
);
52+
}
53+
54+
export function meetsPublicTechReviewThreshold(
55+
review: QualityReview | undefined
56+
): boolean {
57+
const average = getCoreTechReviewAverage(review);
58+
59+
return (
60+
average !== null &&
61+
average > PUBLICATION_POLICY.publicTech.minimumCoreReviewAverageExclusive
62+
);
63+
}
64+
65+
export function isEligibleForFeaturedPost(
66+
post: Pick<FeedData, 'category' | 'qualityReview' | 'series'>
67+
): boolean {
68+
const brandFit = post.qualityReview?.brandFit;
69+
70+
return (
71+
post.category === PUBLICATION_POLICY.featured.category &&
72+
!post.series &&
73+
typeof brandFit === 'number' &&
74+
brandFit >= PUBLICATION_POLICY.featured.minimumBrandFit
75+
);
76+
}

blog/services/post-repository.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
filterVisiblePosts,
77
isPostVisible,
88
type PublicationQueryOptions,
9-
} from './publication-policy';
9+
} from './policy';
1010

1111
const postsDirectory = path.join(process.cwd(), 'posts');
1212
const isProduction = process.env.NODE_ENV === 'production';

blog/services/publication-policy.ts

Lines changed: 0 additions & 19 deletions
This file was deleted.

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ Last updated: 2026-07-22
4646
- `docs/adr/0045-use-team-context-in-resume-company-column.md`
4747
- `docs/adr/0046-use-main-as-the-primary-branch.md`
4848
- `docs/adr/0047-isolate-publication-policy.md`
49+
- `docs/adr/0048-centralize-blog-publication-rules.md`
4950
- `docs/blog-quality-guide.md`
5051
- `docs/content-publication-candidates.md`
5152
- `docs/database/db-schema.md`
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# 0048. 블로그 발행 규칙을 단일 policy 모듈로 확장한다
2+
3+
Date: 2026-07-22
4+
Status: Accepted
5+
6+
## 배경
7+
8+
ADR 0047은 public/private visibility 판단을 `publication-policy.ts`로 분리했다.
9+
그러나 공개 Tech 글의 core 점수 하한과 featured 자격 조건은 테스트 안에 남아
10+
있어, 사람이 읽는 guide와 실행되는 검증의 기준값을 함께 바꿔야 했다.
11+
12+
## 결정
13+
14+
- `blog/services/publication-policy.ts``blog/services/policy.ts`로 바꾼다.
15+
- `policy.ts`는 visibility, 공개 Tech 글의 core 리뷰 하한, featured 자격 조건을
16+
함께 소유한다.
17+
- `meta.json`은 글별 visibility, 점수, featured 선택을 계속 소유한다. featured
18+
slug 목록이나 series의 현재 공개 상태를 policy에 복제하지 않는다.
19+
- frontmatter의 값 범위 검증은 입력 schema 책임으로, 개발 환경 preview는 상세
20+
route의 단일 호출 책임으로 유지한다.
21+
22+
## 결과
23+
24+
- 실행되는 공개 기준값은 `policy.ts` 한 곳에서 바꾼다.
25+
- guide는 정책의 의미와 운영 절차를 설명하고, threshold 숫자의 별도 원본이 되지
26+
않는다.
27+
- 수동 큐레이션 데이터와 발행 규칙이 이중 관리되지 않는다.
28+
29+
## 검토한 대안
30+
31+
- featured slug를 policy에 등록: frontmatter의 `featured`와 두 원본이 된다.
32+
- 점수 기준을 frontmatter schema로 이동: 유효한 입력값과 공개 판단을 섞는다.
33+
- route별 preview 조건까지 이동: 현재 한 파일의 두 호출만 공유하므로 불필요한
34+
wrapper가 된다.
35+
36+
## Related History
37+
38+
- `c43af1e`: visibility 정책을 `publication-policy.ts`로 분리

docs/adr/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ ADR은 AI 협업 가이드와 별개의 문서다. 사람이 결정했든 AI가
6565
| [0045](0045-use-team-context-in-resume-company-column.md) | Accepted | Resume 회사 열에는 팀 맥락만 표시한다 |
6666
| [0046](0046-use-main-as-the-primary-branch.md) | Accepted | 저장소의 주 브랜치를 main으로 통일한다 |
6767
| [0047](0047-isolate-publication-policy.md) | Accepted | 공개 정책을 blog 도메인 모듈로 분리한다 |
68+
| [0048](0048-centralize-blog-publication-rules.md) | Accepted | 블로그 발행 규칙을 단일 policy 모듈로 확장한다 |
6869

6970
## 작성 조건
7071

docs/blog-quality-guide.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,11 +103,12 @@ Life 글은 아래 기준으로 본다.
103103
- `visibility` 기본값은 `private`다. 공개는 리뷰를 마친 뒤 명시적으로 승격한다.
104104
- `contentType``essay`, `retrospective`, `review` 중 하나로 둔다.
105105
- 시리즈는 기본적으로 공개 자산이 아니라 학습 자산으로 보고, 공개 필요성이 생기기 전까지 `private`로 둔다.
106-
- 엔지니어링 글은 `philosophy`, `design`, `implementation` 평균이 `3.0` 이하이면 `private`로 둔다.
106+
- 엔지니어링 글은 `philosophy`, `design`, `implementation` 평균이
107+
`blog/services/policy.ts`의 공개 하한을 넘어야 한다.
107108
- `featured`는 아래 조건을 모두 만족할 때만 허용한다.
108109
- 엔지니어링 글
109110
- 시리즈 아님
110-
- `brandFit >= 4.0`
111+
- `blog/services/policy.ts``brandFit` 하한 충족
111112
- `featured`는 점수만으로 자동 결정하지 않는다. 현재 단계에서는 수동 큐레이션 목록으로 관리한다.
112113
- 현재 홈은 `featured` 글을 별도 노출하지 않고 전체 공개 글을 단일 피드로 보여준다.
113114

0 commit comments

Comments
 (0)