Skip to content

Commit 7fbfae1

Browse files
dev-wooyeon박은우
andauthored
refactor(blog): 공개 목록 정책과 시리즈 surface 정리 (#119)
* docs(blog): 블로그 시스템 구축기 제목 검토 반영 공개 전 콘텐츠 리뷰 결과를 반영해 제목과 검토 메모를 갱신했습니다. * refactor(blog): 공개 정책 경계 분리 목록, static params, 상세 조회가 하나의 visibility 정책을 공유하도록 모듈을 추출했습니다. private 기본값과 preview 동작을 검증하고 ADR로 경계를 기록했습니다. * refactor(blog): 상세 MDX 토큰 정렬 글 상세 헤더와 MDX 본문 표면을 semantic color와 radius token으로 통일했습니다. 토큰 적용을 회귀 테스트로 고정했습니다. * refactor(blog): 발행 정책 중앙화 공개 Tech 글의 리뷰 하한과 featured 자격을 policy 모듈의 실행 기준으로 통합했습니다. 글별 큐레이션 데이터는 meta.json에 유지하고 ADR로 경계를 기록했습니다. * fix(content): 감사 정책 기준 공유 Node 기반 content audit이 앱 정책과 같은 JSON 기준을 사용하도록 변경했습니다. 공개 Tech와 featured 검증·요약의 기준 불일치를 방지했습니다. * fix(blog): 공개 목록 발행 정책 적용 검토 기준을 충족하지 못한 public 글과 부적격 featured 글이 목록, 상세, static path에 노출되지 않도록 repository 경계에서 정책을 적용했습니다. * refactor(blog): 시리즈 탐색 surface 제거 사용되지 않는 시리즈 route, 탐색 컴포넌트, model helper와 Archive 필터를 제거하고 legacy /series 요청은 Engineering으로 정리했습니다. * chore(deps): Next와 React patch 업데이트 Next 16.2.10과 React 19.2.7로 patch 업데이트하고 production build를 확인했습니다. --------- Co-authored-by: 박은우 <noah@bag-eun-uui-MacBookAir.local>
1 parent 79056bd commit 7fbfae1

35 files changed

Lines changed: 225 additions & 1094 deletions

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@ ADR 작성 기준:
4242
## 프로젝트 구조
4343

4444
- `app/`: Next.js App Router route adapter, route handler, metadata entry
45-
- `blog/`: 글 도메인. post schema, repository, publication policy, series,
46-
blog UI, RSS feed serialization, view-count use case
45+
- `blog/`: 글 도메인. post schema, repository, publication policy, blog UI,
46+
RSS feed serialization, view-count use case
4747
- `resume/`: 이력서 도메인. resume data, ordering, resume UI
4848
- `site/`: 도메인 조합 layer. home, AppShell, navigation, provider, site config
4949
- `infra/`: 외부/런타임 인프라. Supabase, Umami analytics, SEO helper,

app/engineering/series/[seriesId]/page.tsx

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

app/sitemap.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ describe('sitemap', () => {
3838
);
3939
expect(urls).not.toContain(`${SITE_URL}/blog`);
4040
expect(urls).not.toContain(`${SITE_URL}/series`);
41+
expect(
42+
urls.some((url) => url.includes('/engineering/series/'))
43+
).toBe(false);
4144
});
4245

4346
it('uses updated date for post lastModified when available', () => {

app/sitemap.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { MetadataRoute } from 'next';
2-
import { getSeriesSummaries } from '@/blog/model/series-group';
32
import { getSortedFeedData } from '@/blog/services/post-repository';
43
import {
54
SITE_FEED_PATH,
@@ -17,15 +16,6 @@ export default function sitemap(): MetadataRoute.Sitemap {
1716
priority: 0.7,
1817
}));
1918

20-
const seriesEntries = getSeriesSummaries(
21-
feeds.filter((feed) => feed.category === 'Tech')
22-
).map((series) => ({
23-
url: createSiteUrl(`/engineering/series/${encodeURIComponent(series.id)}`),
24-
lastModified: series.latestDate,
25-
changeFrequency: 'weekly' as const,
26-
priority: 0.6,
27-
}));
28-
2919
const routePaths: SitePath[] = [
3020
'',
3121
'/archive',
@@ -43,5 +33,5 @@ export default function sitemap(): MetadataRoute.Sitemap {
4333
priority: 1,
4434
}));
4535

46-
return [...routes, ...seriesEntries, ...feedEntries];
36+
return [...routes, ...feedEntries];
4737
}

blog/model/series-group.test.ts

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

blog/model/series-group.ts

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

blog/services/policy.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import { describe, expect, it } from 'vitest';
44
import type { FeedData } from '@/blog/model/types';
55
import { getAllFeedSlugs, getSortedFeedData } from './post-repository';
66
import {
7+
filterListedPosts,
78
filterVisiblePosts,
89
getCoreTechReviewAverage,
910
isEligibleForFeaturedPost,
11+
isListedPost,
1012
isPostVisible,
1113
meetsPublicTechReviewThreshold,
1214
PUBLICATION_POLICY,
@@ -64,6 +66,29 @@ describe('publication policy', () => {
6466
).toBe(false);
6567
});
6668

69+
it('excludes public tech posts that fail review thresholds from public listings', () => {
70+
const belowThresholdPost: FeedData = {
71+
title: 'Below threshold',
72+
slug: 'below-threshold',
73+
description: 'desc',
74+
date: '2026-01-01',
75+
category: 'Tech',
76+
contentType: 'essay',
77+
visibility: 'public',
78+
qualityReview: {
79+
philosophy: 2,
80+
design: 2,
81+
implementation: 2,
82+
},
83+
};
84+
85+
expect(isListedPost(belowThresholdPost)).toBe(false);
86+
expect(isListedPost(belowThresholdPost, { includePrivate: true })).toBe(
87+
true
88+
);
89+
expect(filterListedPosts([belowThresholdPost])).toEqual([]);
90+
});
91+
6792
it('keeps every private post out of public listings and static paths', () => {
6893
const allPosts = getSortedFeedData({ includePrivate: true });
6994
const privatePosts = allPosts.filter(

blog/services/policy.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,48 @@ export function isPostVisible(
1717
return options.includePrivate || post.visibility === 'public';
1818
}
1919

20+
export function isListedPost(
21+
post: FeedData,
22+
options: PublicationQueryOptions = {}
23+
): boolean {
24+
const visibility = post.visibility ?? 'private';
25+
26+
if (visibility !== 'public') {
27+
return options.includePrivate === true;
28+
}
29+
30+
if (options.includePrivate) {
31+
return true;
32+
}
33+
34+
if (
35+
post.category === 'Tech' &&
36+
!meetsPublicTechReviewThreshold(post.qualityReview)
37+
) {
38+
return false;
39+
}
40+
41+
if (post.featured && !isEligibleForFeaturedPost(post)) {
42+
return false;
43+
}
44+
45+
return true;
46+
}
47+
2048
export function filterVisiblePosts<T extends Pick<FeedData, 'visibility'>>(
2149
posts: T[],
2250
options: PublicationQueryOptions = {}
2351
): T[] {
2452
return posts.filter((post) => isPostVisible(post, options));
2553
}
2654

55+
export function filterListedPosts<T extends FeedData>(
56+
posts: T[],
57+
options: PublicationQueryOptions = {}
58+
): T[] {
59+
return posts.filter((post) => isListedPost(post, options));
60+
}
61+
2762
export function getCoreTechReviewAverage(
2863
review: QualityReview | undefined
2964
): number | null {

blog/services/post-repository.test.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
calculateReadingTime,
88
getFeedData,
99
getFolderSlug,
10-
getSeriesPosts,
1110
getSortedFeedData,
1211
getAllFeedSlugs,
1312
} from './post-repository';
@@ -60,19 +59,6 @@ ${'x'.repeat(5000)}
6059
expect(hasExtractedImage).toBe(true);
6160
});
6261

63-
it('does not retain learning series migrated to llm-wiki', () => {
64-
expect(getSeriesPosts('redis-deep-dive', { includePrivate: true })).toEqual(
65-
[]
66-
);
67-
expect(getSeriesPosts('flink-mastery', { includePrivate: true })).toEqual(
68-
[]
69-
);
70-
});
71-
72-
it('returns empty array for unknown series id', () => {
73-
expect(getSeriesPosts('non-existent-series-id')).toEqual([]);
74-
});
75-
7662
it('returns slug list with all feed entries', () => {
7763
const slugs = getAllFeedSlugs();
7864

0 commit comments

Comments
 (0)