Skip to content

Commit 79056bd

Browse files
authored
refactor(blog): 발행 정책과 MDX 토큰 정렬 (#118)
* 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 검증·요약의 기준 불일치를 방지했습니다.
1 parent 4623de1 commit 79056bd

18 files changed

Lines changed: 404 additions & 113 deletions

blog/services/policy.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"publicTech": {
3+
"coreReviewFields": ["philosophy", "design", "implementation"],
4+
"minimumCoreReviewAverageExclusive": 3
5+
},
6+
"featured": {
7+
"category": "Tech",
8+
"minimumBrandFit": 4,
9+
"requiresNoSeries": true
10+
}
11+
}
Lines changed: 60 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +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 {
7+
filterVisiblePosts,
8+
getCoreTechReviewAverage,
9+
isEligibleForFeaturedPost,
10+
isPostVisible,
11+
meetsPublicTechReviewThreshold,
12+
PUBLICATION_POLICY,
13+
} from './policy';
614

715
const FEATURED_SLUGS = [
816
'ctr-pipeline',
@@ -11,22 +19,51 @@ const FEATURED_SLUGS = [
1119
'msa-domain-workspace-submodule',
1220
];
1321

14-
function readCoreAverage(review: QualityReview | undefined): number | null {
15-
const scores = [review?.philosophy, review?.design, review?.implementation];
16-
17-
if (scores.some((score) => typeof score !== 'number')) {
18-
return null;
19-
}
20-
21-
const [philosophy, design, implementation] = scores as number[];
22-
return (philosophy + design + implementation) / 3;
23-
}
24-
2522
function describePost(post: FeedData): string {
2623
return `${post.slug} (${post.title})`;
2724
}
2825

2926
describe('publication policy', () => {
27+
it('keeps private posts hidden unless a caller explicitly requests a preview', () => {
28+
const unspecifiedPost = {};
29+
const privatePost = { visibility: 'private' };
30+
const publicPost = { visibility: 'public' };
31+
32+
expect(isPostVisible(unspecifiedPost)).toBe(false);
33+
expect(isPostVisible(privatePost)).toBe(false);
34+
expect(isPostVisible(publicPost)).toBe(true);
35+
expect(isPostVisible(privatePost, { includePrivate: true })).toBe(true);
36+
expect(filterVisiblePosts([privatePost, publicPost])).toEqual([publicPost]);
37+
});
38+
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+
3067
it('keeps every private post out of public listings and static paths', () => {
3168
const allPosts = getSortedFeedData({ includePrivate: true });
3269
const privatePosts = allPosts.filter(
@@ -57,17 +94,17 @@ describe('publication policy', () => {
5794
const offenses = getSortedFeedData()
5895
.filter((post) => post.category === 'Tech')
5996
.flatMap((post) => {
60-
const average = readCoreAverage(post.qualityReview);
97+
const average = getCoreTechReviewAverage(post.qualityReview);
6198

62-
if (average === null) {
63-
return [
64-
`${describePost(post)}: qualityReview core scores are incomplete`,
65-
];
66-
}
99+
if (!meetsPublicTechReviewThreshold(post.qualityReview)) {
100+
if (average === null) {
101+
return [
102+
`${describePost(post)}: qualityReview core scores are incomplete`,
103+
];
104+
}
67105

68-
if (average <= 3) {
69106
return [
70-
`${describePost(post)}: core average ${average.toFixed(2)} <= 3.0`,
107+
`${describePost(post)}: core average ${average.toFixed(2)} <= ${PUBLICATION_POLICY.publicTech.minimumCoreReviewAverageExclusive.toFixed(1)}`,
71108
];
72109
}
73110

@@ -80,26 +117,9 @@ describe('publication policy', () => {
80117
it('requires featured posts to meet branding thresholds', () => {
81118
const featuredPosts = getSortedFeedData().filter((post) => post.featured);
82119
const offenses = featuredPosts.flatMap((post) => {
83-
const brandFit = post.qualityReview?.brandFit;
84-
const currentOffenses: string[] = [];
85-
86-
if (post.category !== 'Tech') {
87-
currentOffenses.push(
88-
`${describePost(post)}: featured posts must be Tech`
89-
);
90-
}
91-
92-
if (post.series) {
93-
currentOffenses.push(
94-
`${describePost(post)}: featured posts must not be series`
95-
);
96-
}
97-
98-
if (typeof brandFit !== 'number' || brandFit < 4) {
99-
currentOffenses.push(`${describePost(post)}: brandFit must be >= 4.0`);
100-
}
101-
102-
return currentOffenses;
120+
return isEligibleForFeaturedPost(post)
121+
? []
122+
: [`${describePost(post)}: does not meet featured criteria`];
103123
});
104124

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

blog/services/policy.ts

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

blog/services/post-repository.ts

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import fs from 'fs';
22
import path from 'path';
33
import type { FeedData, Feed, FeedFrontmatter } from '@/blog/model/types';
44
import { FeedFrontmatterSchema } from '@/blog/model/frontmatter-schema';
5+
import {
6+
filterVisiblePosts,
7+
isPostVisible,
8+
type PublicationQueryOptions,
9+
} from './policy';
510

611
const postsDirectory = path.join(process.cwd(), 'posts');
712
const isProduction = process.env.NODE_ENV === 'production';
@@ -19,9 +24,7 @@ function logContentIssue(message: string): void {
1924
console.warn(`[post-repository] ${message}`);
2025
}
2126

22-
export interface FeedQueryOptions {
23-
includePrivate?: boolean;
24-
}
27+
export type FeedQueryOptions = PublicationQueryOptions;
2528

2629
// TOC item type
2730
export interface TocItem {
@@ -248,21 +251,6 @@ function loadMetadata(folderPath: string): FeedFrontmatter | null {
248251
}
249252
}
250253

251-
function isPublicPost(post: FeedData): boolean {
252-
return (post.visibility ?? 'private') === 'public';
253-
}
254-
255-
function filterVisiblePosts(
256-
posts: FeedData[],
257-
options: FeedQueryOptions = {}
258-
): FeedData[] {
259-
if (options.includePrivate) {
260-
return posts;
261-
}
262-
263-
return posts.filter(isPublicPost);
264-
}
265-
266254
// Get folder path from slug (using cache or scanning)
267255
export function getFolderSlug(slug: string): string | null {
268256
// Check cache first
@@ -359,7 +347,7 @@ export async function getFeedData(
359347
return null;
360348
}
361349

362-
if (!options.includePrivate && metadata.visibility === 'private') {
350+
if (!isPostVisible(metadata, options)) {
363351
return null;
364352
}
365353

blog/ui/mdx/components.test.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,18 @@ describe('getMDXComponents', () => {
4747
expect(screen.getByText('본문 문단')).not.toHaveClass('text-base');
4848
expect(screen.getByText('본문 문단')).not.toHaveClass('leading-relaxed');
4949
});
50+
51+
it('uses semantic tokens for MDX headings', () => {
52+
const { h2: Heading } = getMDXComponents({});
53+
54+
if (!Heading) {
55+
throw new Error('Expected heading component to be defined');
56+
}
57+
58+
render(<Heading>제목</Heading>);
59+
60+
expect(screen.getByRole('heading', { name: '제목' })).toHaveClass(
61+
'text-[var(--color-text-primary)]'
62+
);
63+
});
5064
});

blog/ui/mdx/components.tsx

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,7 @@ import {
77
type ReactNode,
88
} from 'react';
99
import Image from 'next/image';
10-
import {
11-
ImageGrid,
12-
MermaidDiagram,
13-
} from '@/blog/ui/components';
10+
import { ImageGrid, MermaidDiagram } from '@/blog/ui/components';
1411

1512
function extractText(node: ReactNode): string {
1613
if (node == null || typeof node === 'boolean') return '';
@@ -55,35 +52,35 @@ export function getMDXComponents(components: MDXComponents): MDXComponents {
5552
return {
5653
h1: createHeading(
5754
'h1',
58-
'text-3xl font-bold mt-16 mb-8 text-[var(--color-grey-900)]'
55+
'mt-16 mb-8 text-3xl font-bold text-[var(--color-text-primary)]'
5956
),
6057
h2: createHeading(
6158
'h2',
62-
'mt-12 mb-6 text-2xl font-bold text-[var(--color-grey-900)]'
59+
'mt-12 mb-6 text-2xl font-bold text-[var(--color-text-primary)]'
6360
),
6461
h3: createHeading(
6562
'h3',
66-
'mt-8 mb-4 text-xl font-bold text-[var(--color-grey-900)]'
63+
'mt-8 mb-4 text-xl font-bold text-[var(--color-text-primary)]'
6764
),
6865
h4: createHeading(
6966
'h4',
70-
'mt-8 mb-4 text-lg font-bold text-[var(--color-grey-900)]'
67+
'mt-8 mb-4 text-lg font-bold text-[var(--color-text-primary)]'
7168
),
7269
h5: createHeading(
7370
'h5',
74-
'mt-8 mb-4 text-base font-bold text-[var(--color-grey-900)]'
71+
'mt-8 mb-4 text-base font-bold text-[var(--color-text-primary)]'
7572
),
7673
h6: createHeading(
7774
'h6',
78-
'mt-8 mb-4 text-base font-bold text-[var(--color-grey-900)]'
75+
'mt-8 mb-4 text-base font-bold text-[var(--color-text-primary)]'
7976
),
8077
p: (props) => <p {...props}>{props.children}</p>,
8178
img: (props) => {
8279
// Improved null safety - return null if no src
8380
if (!props.src) return null;
8481

8582
return (
86-
<span className="block my-12 overflow-hidden rounded-[16px]">
83+
<span className="my-12 block overflow-hidden rounded-[var(--radius-content)]">
8784
<Image
8885
src={props.src}
8986
alt={props.alt || ''}
@@ -96,10 +93,10 @@ export function getMDXComponents(components: MDXComponents): MDXComponents {
9693
objectFit: 'cover',
9794
}}
9895
priority={props.src?.includes('thumbnail')}
99-
className="rounded-[16px]"
96+
className="block w-full"
10097
/>
10198
{props.alt && (
102-
<span className="block text-center text-sm text-[var(--color-grey-600)] mt-4 mb-2 px-4">
99+
<span className="mt-4 mb-2 block px-4 text-center text-sm text-[var(--color-text-tertiary)]">
103100
{props.alt}
104101
</span>
105102
)}

blog/ui/pages/BlogPostPage.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -158,29 +158,29 @@ export default async function BlogPostPage({
158158
<Container size="md">
159159
{/* Header */}
160160
<header className="mb-10">
161-
<span className="inline-block px-3 py-1 text-sm font-medium text-[var(--color-toss-blue)] bg-[var(--color-toss-blue)]/10 rounded-full mb-4">
161+
<span className="mb-4 inline-block rounded-[var(--radius-selection)] bg-[var(--color-bg-secondary)] px-3 py-1 text-sm font-medium text-[var(--color-text-secondary)]">
162162
{post.category}
163163
</span>
164-
<h1 className="text-3xl md:text-4xl font-bold text-[var(--color-grey-900)] leading-tight">
164+
<h1 className="text-3xl font-bold leading-tight text-[var(--color-text-primary)] md:text-4xl">
165165
{post.title}
166166
</h1>
167-
<div className="mt-6 flex flex-wrap items-center gap-4 text-sm text-[var(--color-grey-500)]">
167+
<div className="mt-6 flex flex-wrap items-center gap-4 text-sm text-[var(--color-text-tertiary)]">
168168
<time>{formattedDate}</time>
169169
{readingTimeLabel && (
170170
<>
171-
<span className="w-1 h-1 bg-[var(--color-grey-300)] rounded-full" />
171+
<span className="h-1 w-1 rounded-full bg-[var(--color-divider)]" />
172172
<span>{readingTimeLabel}</span>
173173
</>
174174
)}
175-
<span className="w-1 h-1 bg-[var(--color-grey-300)] rounded-full" />
175+
<span className="h-1 w-1 rounded-full bg-[var(--color-divider)]" />
176176
<ViewCounter slug={post.slug} />
177177
</div>
178178
{post.tags && (
179179
<div className="mt-4 flex flex-wrap items-center gap-2">
180180
{post.tags.map((tag) => (
181181
<span
182182
key={tag}
183-
className="rounded-full bg-[var(--color-grey-100)] px-2.5 py-1.5 text-xs font-medium text-[var(--color-grey-500)]"
183+
className="rounded-[var(--radius-selection)] bg-[var(--color-bg-secondary)] px-2.5 py-1.5 text-xs font-medium text-[var(--color-text-tertiary)]"
184184
>
185185
#{tag}
186186
</span>

0 commit comments

Comments
 (0)