Skip to content

Commit d7e5a99

Browse files
authored
feat(content): 글 형식 audit와 LLM Wiki 구축기 추가 (#111)
* fix(blog): 조회수와 콘텐츠 로깅 안정화 빌드 중 Supabase 조회수 집계 쿼리를 건너뛰어 production build 재현성을 높였습니다. 콘텐츠 로딩과 조회수 실패 로그는 민감한 경로나 원본 오류를 직접 노출하지 않도록 정리했습니다. ViewCounter의 sessionStorage 접근과 Mermaid 보안 설정도 함께 보강했습니다. * feat(content): 글 형식과 성장 audit 추가 category와 contentType을 분리하고 기존 글 meta에 형식 분류를 부여했습니다. qualityReview에 글쓰기 점수 축을 확장하고 content:audit, new-post scaffold, repo-local blog-growth-review skill을 추가했습니다. 점수는 v1에서 공개 UI에 노출하지 않고 작성과 리뷰 운영 도구로만 사용합니다. * chore(content): 글쓰기 점수 초기 채점 blog-growth-review 기준으로 공개 글과 private 시리즈 글의 writing score를 초기 채점했습니다. 기존 core qualityReview 값은 보존하고 clarity, structure, evidence, usefulness, originality, polish만 채웠습니다. content:audit 기준 writing score coverage를 41/41로 맞췄습니다. * feat(seo): 검색 노출 metadata 보강 검색엔진이 대표 URL을 명확히 해석할 수 있도록 canonical, og:url, 게시글별 Twitter metadata를 추가했습니다. sitemap에 공개 허브 라우트를 포함하고 post updated 값을 lastModified에 반영하도록 조정했습니다. * docs(blog): LLM Wiki 구축기 추가 raw, drafts, wiki 신뢰 경계를 중심으로 LLM Wiki 구축 회고 글을 추가했습니다. 로컬에서 private 글을 미리보기할 수 있도록 development preview 정책과 ADR을 추가하고 Playwright 임시 파일을 ignore 처리했습니다. * fix(content): PR 리뷰 정책 오류 수정 contentType을 ingestion 필수 필드로 바꾸고 production build에서도 콘텐츠 진단 로그가 남도록 수정했습니다. Life 글의 writing score 부족은 private 전환 검토가 아니라 보강 필요로 분류하도록 audit 정책과 테스트를 보강했습니다. * fix(blog): 본문 문단 typography 적용 수정
1 parent e72c423 commit d7e5a99

86 files changed

Lines changed: 2279 additions & 217 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
---
2+
name: blog-growth-review
3+
description: Review and score Ark blog posts in this repository. Use when evaluating a post draft or published post for contentType classification, writing quality scores, publication readiness, improvement priorities, or when proposing meta.json qualityReview updates for posts/**/index.mdx and posts/**/meta.json.
4+
---
5+
6+
# Blog Growth Review
7+
8+
## Workflow
9+
10+
1. Read `docs/blog-quality-guide.md`, the target `index.mdx`, and its sibling
11+
`meta.json` before judging the post.
12+
2. Preserve `category: Tech | Life`; classify the writing form with
13+
`contentType: essay | retrospective | review`.
14+
3. Score only for authoring/review operations. Do not expose scores in public UI
15+
unless the user explicitly asks for a UI change.
16+
4. Return a scorecard, publication verdict, and focused improvement checklist.
17+
Do not edit files unless the user asks for implementation.
18+
19+
## contentType Rules
20+
21+
- `essay`: opinion, argument, personal interpretation, principle, or explanatory
22+
guide.
23+
- `retrospective`: event or project review with context, turning points, lessons,
24+
and next actions.
25+
- `review`: evaluation of an external object or experience such as a book, trip,
26+
product, article, or venue.
27+
28+
If multiple types fit, choose the reader expectation that dominates the title and
29+
opening section. Keep technical project writeups as `retrospective` when the
30+
article is organized around a concrete build, incident, migration, or work
31+
outcome.
32+
33+
## Scorecard
34+
35+
Use 1-5 scores in 0.5 increments. Use `null` only when the source is not
36+
available enough to judge.
37+
38+
- `philosophy`: reusable judgment, principle, or decision quality.
39+
- `design`: structure, flow, framing, and section architecture.
40+
- `implementation`: concrete execution detail, examples, evidence, or mechanism.
41+
- `brandFit`: fit with Ark's public identity and author positioning.
42+
- `clarity`: clear thesis, terms, conclusion, and reader promise.
43+
- `structure`: narrative progression and section-level cohesion.
44+
- `evidence`: concrete scenes, data, examples, constraints, or references.
45+
- `usefulness`: reader takeaway, applicability, and decision support.
46+
- `originality`: author's perspective beyond generic summary.
47+
- `polish`: sentence quality, duplication removal, tone consistency, and typos.
48+
49+
## Type-Specific Checks
50+
51+
- `essay`: strong point of view, controlled abstraction, enough concrete anchors,
52+
and a final reusable thought.
53+
- `retrospective`: timeline or situation, decision point, failure or tradeoff,
54+
changed behavior, and next-time rule.
55+
- `review`: evaluation criteria, who should care, what worked or did not, and a
56+
recommendation boundary.
57+
58+
## Output Format
59+
60+
Return:
61+
62+
````markdown
63+
## Verdict
64+
- contentType: ...
65+
- publication: 공개 유지 | 보강 필요 | private 전환 검토
66+
- summary: ...
67+
68+
## Scorecard
69+
| Axis | Score | Reason |
70+
| --- | ---: | --- |
71+
72+
## Improvements
73+
1. ...
74+
2. ...
75+
3. ...
76+
77+
## Suggested meta.json patch
78+
```json
79+
{
80+
"contentType": "...",
81+
"qualityReview": {
82+
"...": "..."
83+
}
84+
}
85+
```
86+
````
87+
88+
Omit the suggested patch when the user asked only for conceptual feedback.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
interface:
2+
display_name: "Blog Growth Review"
3+
short_description: "Score and review Ark blog posts"
4+
default_prompt: "Review this blog post for content type, quality scores, publication readiness, and concrete improvements."

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,5 @@ next-env.d.ts
4141
.husky/
4242
.sisyphus/
4343
.claude/
44+
.playwright-cli/
4445
.idea

app/page.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,22 @@
1+
import type { Metadata } from 'next';
2+
import { SITE_DESCRIPTION, SITE_NAME, createSiteUrl } from '@/site/config/site';
3+
4+
const homeUrl = createSiteUrl();
5+
6+
export const metadata: Metadata = {
7+
alternates: {
8+
canonical: homeUrl,
9+
},
10+
openGraph: {
11+
title: SITE_NAME,
12+
description: SITE_DESCRIPTION,
13+
url: homeUrl,
14+
},
15+
twitter: {
16+
card: 'summary_large_image',
17+
title: SITE_NAME,
18+
description: SITE_DESCRIPTION,
19+
},
20+
};
21+
122
export { default } from '@/site/home/ui/pages/HomePage';

app/sitemap.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,36 @@ describe('sitemap', () => {
2121
);
2222
}
2323
});
24+
25+
it('includes public hub routes for discovery', () => {
26+
const entries = sitemap();
27+
const urls = entries.map((entry) => entry.url);
28+
29+
expect(urls).toEqual(
30+
expect.arrayContaining([
31+
SITE_URL,
32+
`${SITE_URL}/blog`,
33+
`${SITE_URL}/engineering`,
34+
`${SITE_URL}/life`,
35+
`${SITE_URL}/series`,
36+
`${SITE_URL}/resume`,
37+
`${SITE_URL}/rss.xml`,
38+
])
39+
);
40+
});
41+
42+
it('uses updated date for post lastModified when available', () => {
43+
const entries = sitemap();
44+
const publicUpdatedPost = getSortedFeedData().find((post) => post.updated);
45+
46+
if (!publicUpdatedPost) {
47+
return;
48+
}
49+
50+
expect(
51+
entries.find(
52+
(entry) => entry.url === `${SITE_URL}/blog/${publicUpdatedPost.slug}`
53+
)?.lastModified
54+
).toBe(publicUpdatedPost.updated);
55+
});
2456
});

app/sitemap.ts

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,48 @@
11
import { MetadataRoute } from 'next';
2+
import { getSeriesSummaries } from '@/blog/model/series-group';
23
import { getSortedFeedData } from '@/blog/services/post-repository';
3-
import { SITE_FEED_PATH, SITE_URL } from '@/site/config/site';
4+
import {
5+
SITE_FEED_PATH,
6+
createSiteUrl,
7+
type SitePath,
8+
} from '@/site/config/site';
49

510
export default function sitemap(): MetadataRoute.Sitemap {
611
const feeds = getSortedFeedData();
712

813
const feedEntries = feeds.map((feed) => ({
9-
url: `${SITE_URL}/blog/${feed.slug}`,
10-
lastModified: feed.date, // Use actual post date
14+
url: createSiteUrl(`/blog/${feed.slug}`),
15+
lastModified: feed.updated ?? feed.date,
1116
changeFrequency: 'weekly' as const,
1217
priority: 0.7,
1318
}));
1419

15-
const routes = ['', '/engineering', '/life', '/resume', SITE_FEED_PATH].map(
16-
(route) => ({
17-
url: `${SITE_URL}${route}`,
18-
lastModified: new Date().toISOString().split('T')[0],
19-
changeFrequency:
20-
route === SITE_FEED_PATH ? ('daily' as const) : ('monthly' as const),
21-
priority: 1,
22-
})
23-
);
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+
29+
const routePaths: SitePath[] = [
30+
'',
31+
'/blog',
32+
'/engineering',
33+
'/life',
34+
'/series',
35+
'/resume',
36+
SITE_FEED_PATH,
37+
];
38+
39+
const routes = routePaths.map((route) => ({
40+
url: createSiteUrl(route),
41+
lastModified: new Date().toISOString().split('T')[0],
42+
changeFrequency:
43+
route === SITE_FEED_PATH ? ('daily' as const) : ('monthly' as const),
44+
priority: 1,
45+
}));
2446

25-
return [...routes, ...feedEntries];
47+
return [...routes, ...seriesEntries, ...feedEntries];
2648
}

blog/api/view.test.ts

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { beforeEach, describe, expect, it, vi } from 'vitest';
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
22
import { cookies, headers } from 'next/headers';
33
import { getSupabaseServerClient } from '@/infra/integrations/supabase';
44
import {
@@ -78,15 +78,31 @@ function createSupabaseMock(options: {
7878
const mockedGetSupabase = vi.mocked(getSupabaseServerClient);
7979
const mockedCookies = vi.mocked(cookies);
8080
const mockedHeaders = vi.mocked(headers);
81+
const originalNextPhase = process.env.NEXT_PHASE;
82+
const originalNpmLifecycleEvent = process.env.npm_lifecycle_event;
83+
84+
function restoreBuildPhaseEnv() {
85+
if (originalNextPhase === undefined) {
86+
delete process.env.NEXT_PHASE;
87+
} else {
88+
process.env.NEXT_PHASE = originalNextPhase;
89+
}
90+
91+
if (originalNpmLifecycleEvent === undefined) {
92+
delete process.env.npm_lifecycle_event;
93+
} else {
94+
process.env.npm_lifecycle_event = originalNpmLifecycleEvent;
95+
}
96+
}
8197

82-
function createCookieStoreMock(visitorId: string | null = null): CookieStoreLike {
98+
function createCookieStoreMock(
99+
visitorId: string | null = null
100+
): CookieStoreLike {
83101
return {
84102
get: vi
85103
.fn()
86104
.mockReturnValue(
87-
visitorId
88-
? { name: 'view_visitor_id', value: visitorId }
89-
: null
105+
visitorId ? { name: 'view_visitor_id', value: visitorId } : null
90106
),
91107
set: vi.fn(),
92108
};
@@ -102,6 +118,7 @@ function createHeaderStoreMock(
102118

103119
describe('view actions', () => {
104120
beforeEach(() => {
121+
restoreBuildPhaseEnv();
105122
vi.clearAllMocks();
106123
mockedCookies.mockResolvedValue(createCookieStoreMock());
107124
mockedHeaders.mockResolvedValue(
@@ -113,6 +130,10 @@ describe('view actions', () => {
113130
);
114131
});
115132

133+
afterEach(() => {
134+
restoreBuildPhaseEnv();
135+
});
136+
116137
it('increments view when slug is valid and client exists', async () => {
117138
const client = createSupabaseMock({
118139
queryPayload: { data: { count: 10 }, error: null },
@@ -254,6 +275,29 @@ describe('view actions', () => {
254275
);
255276
});
256277

278+
it('uses standard client hints platform header for fingerprinting', async () => {
279+
const client = createSupabaseMock({
280+
queryPayload: { data: { count: 1 }, error: null },
281+
rpcPayload: { data: 5, error: null },
282+
});
283+
const headerStore = createHeaderStoreMock({
284+
'x-forwarded-for': '203.0.113.10',
285+
'user-agent': 'Vitest Browser',
286+
'accept-language': 'ko-KR',
287+
'sec-ch-ua': '"Chromium";v="126"',
288+
'sec-ch-ua-platform': '"macOS"',
289+
});
290+
mockedGetSupabase.mockReturnValue(client);
291+
mockedHeaders.mockResolvedValue(headerStore);
292+
293+
await trackView('my-post');
294+
295+
expect(headerStore.get).toHaveBeenCalledWith('sec-ch-ua-platform');
296+
expect(headerStore.get).not.toHaveBeenCalledWith(
297+
'sec-ch-ua-infrastructure'
298+
);
299+
});
300+
257301
it('falls back to legacy increment signature when rpc argument mismatch occurs', async () => {
258302
const client = {
259303
from: vi.fn().mockReturnValue({
@@ -379,6 +423,24 @@ describe('view actions', () => {
379423
expect(result).toEqual([]);
380424
});
381425

426+
it('skips popular query during production build phase', async () => {
427+
process.env.NEXT_PHASE = 'phase-production-build';
428+
mockedGetSupabase.mockReturnValue(
429+
createSupabaseMock({
430+
queryPayload: {
431+
data: [{ slug: 'a', count: 10, updated_at: '2026-03-05' }],
432+
error: null,
433+
},
434+
rpcPayload: { data: 0, error: null },
435+
})
436+
);
437+
438+
const result = await getPopularViewsInRecentDays(30, 5);
439+
440+
expect(result).toEqual([]);
441+
expect(mockedGetSupabase).not.toHaveBeenCalled();
442+
});
443+
382444
it('normalizes invalid day/limit inputs for popular query', async () => {
383445
const client = createSupabaseMock({
384446
queryPayload: {

0 commit comments

Comments
 (0)