Skip to content

Commit 369a8bf

Browse files
committed
feat(seo): 검색 노출 metadata 보강
검색엔진이 대표 URL을 명확히 해석할 수 있도록 canonical, og:url, 게시글별 Twitter metadata를 추가했습니다. sitemap에 공개 허브 라우트를 포함하고 post updated 값을 lastModified에 반영하도록 조정했습니다.
1 parent f3a05fd commit 369a8bf

11 files changed

Lines changed: 240 additions & 26 deletions

File tree

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/ui/pages/BlogListPage.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,23 @@
11
import { Metadata } from 'next';
22
import { getSortedFeedData } from '@/blog/services/post-repository';
33
import { Container } from '@/ui/layout';
4+
import { createSiteUrl } from '@/site/config/site';
45
import BlogListClient from './BlogListClient';
56

7+
const blogUrl = createSiteUrl('/blog');
8+
const description = '개발과 일상에 대한 이야기를 나눕니다';
9+
610
export const metadata: Metadata = {
711
title: 'Blog',
8-
description: '개발과 일상에 대한 이야기를 나눕니다',
12+
description,
13+
alternates: {
14+
canonical: blogUrl,
15+
},
16+
openGraph: {
17+
title: 'Blog',
18+
description,
19+
url: blogUrl,
20+
},
921
};
1022

1123
export default function BlogPage() {

blog/ui/pages/BlogPostPage.tsx

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,32 @@ import DwellTimeTracker from '@/infra/analytics/components/DwellTimeTracker';
2222
import ScrollDepthTracker from '@/infra/analytics/components/ScrollDepthTracker';
2323
import JsonLd from '@/infra/seo/JsonLd';
2424
import { getMDXComponents } from '@/blog/ui/mdx/components';
25-
import { SITE_URL } from '@/site/config/site';
25+
import { SITE_AUTHOR, createSiteUrl } from '@/site/config/site';
26+
27+
function createPostUrl(slug: string): string {
28+
return createSiteUrl(`/blog/${slug}`);
29+
}
30+
31+
function createPostOgImageUrl({
32+
title,
33+
date,
34+
tags,
35+
}: {
36+
title: string;
37+
date: string;
38+
tags?: string[];
39+
}): string {
40+
const params = new URLSearchParams({
41+
title,
42+
date,
43+
});
44+
45+
if (tags && tags.length > 0) {
46+
params.set('tags', tags.join(','));
47+
}
48+
49+
return `${createSiteUrl('/api/og')}?${params.toString()}`;
50+
}
2651

2752
export async function generateStaticParams() {
2853
return getAllFeedSlugs();
@@ -40,25 +65,45 @@ export async function generateMetadata({
4065
return { title: '글을 찾을 수 없습니다' };
4166
}
4267

68+
const postUrl = createPostUrl(post.slug);
69+
const ogImageUrl = createPostOgImageUrl(post);
70+
const modifiedTime = post.updated ?? post.date;
71+
4372
return {
4473
title: post.title,
4574
description: post.description,
75+
alternates: {
76+
canonical: postUrl,
77+
},
4678
openGraph: {
4779
title: post.title,
4880
description: post.description,
81+
url: postUrl,
4982
type: 'article',
5083
publishedTime: post.date,
51-
authors: ['Eunu'],
84+
modifiedTime,
85+
authors: [SITE_AUTHOR.name],
5286
tags: post.tags,
5387
images: [
5488
{
55-
url: `/api/og?title=${encodeURIComponent(post.title)}&date=${post.date}&tags=${post.tags?.join(',') || ''}`,
89+
url: ogImageUrl,
5690
width: 1200,
5791
height: 630,
5892
alt: post.title,
5993
},
6094
],
6195
},
96+
twitter: {
97+
card: 'summary_large_image',
98+
title: post.title,
99+
description: post.description,
100+
images: [
101+
{
102+
url: ogImageUrl,
103+
alt: post.title,
104+
},
105+
],
106+
},
62107
};
63108
}
64109

@@ -88,6 +133,9 @@ export default async function BlogPostPage({
88133
});
89134
const readingTimeLabel = post.readingTime ? `약 ${post.readingTime}분` : null;
90135
const mdxComponents = getMDXComponents({});
136+
const postUrl = createPostUrl(post.slug);
137+
const ogImageUrl = createPostOgImageUrl(post);
138+
const modifiedTime = post.updated ?? post.date;
91139

92140
return (
93141
<>
@@ -163,16 +211,25 @@ export default async function BlogPostPage({
163211
data={{
164212
'@context': 'https://schema.org',
165213
'@type': 'BlogPosting',
214+
mainEntityOfPage: {
215+
'@type': 'WebPage',
216+
'@id': postUrl,
217+
},
218+
url: postUrl,
166219
headline: post.title,
167220
description: post.description,
168221
author: {
169222
'@type': 'Person',
170-
name: 'Eunu',
223+
name: SITE_AUTHOR.name,
224+
url: SITE_AUTHOR.profileUrl,
225+
sameAs: SITE_AUTHOR.sameAs,
171226
},
172227
datePublished: post.date,
173-
image: [
174-
`${SITE_URL}/api/og?title=${encodeURIComponent(post.title)}&date=${post.date}&tags=${post.tags?.join(',') || ''}`,
175-
],
228+
dateModified: modifiedTime,
229+
image: [ogImageUrl],
230+
keywords: post.tags ?? [],
231+
inLanguage: 'ko-KR',
232+
isAccessibleForFree: true,
176233
}}
177234
/>
178235

blog/ui/pages/EngineeringPage.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,23 @@ import { Metadata } from 'next';
22
import { Suspense } from 'react';
33
import { getSortedFeedData } from '@/blog/services/post-repository';
44
import { Container } from '@/ui/layout';
5+
import { createSiteUrl } from '@/site/config/site';
56
import EngineeringPageClient from './EngineeringPageClient';
67

8+
const engineeringUrl = createSiteUrl('/engineering');
9+
const description = '기술 글을 한 흐름에서 탐색할 수 있어요';
10+
711
export const metadata: Metadata = {
812
title: 'Engineering',
9-
description: '기술 글을 한 흐름에서 탐색할 수 있어요',
13+
description,
14+
alternates: {
15+
canonical: engineeringUrl,
16+
},
17+
openGraph: {
18+
title: 'Engineering',
19+
description,
20+
url: engineeringUrl,
21+
},
1022
};
1123

1224
export default function EngineeringPage() {

blog/ui/pages/LifePage.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,28 @@ import { Metadata } from 'next';
22
import { getSortedFeedData } from '@/blog/services/post-repository';
33
import { Container } from '@/ui/layout';
44
import { PostList } from '@/blog/ui/components';
5+
import { createSiteUrl } from '@/site/config/site';
6+
7+
const lifeUrl = createSiteUrl('/life');
8+
const description = '일상에서 배운 점과 오래 남은 생각을 차분하게 정리해요';
59

610
export const metadata: Metadata = {
711
title: 'Life',
8-
description: '일상에서 배운 점과 오래 남은 생각을 차분하게 정리해요',
12+
description,
13+
alternates: {
14+
canonical: lifeUrl,
15+
},
16+
openGraph: {
17+
title: 'Life',
18+
description,
19+
url: lifeUrl,
20+
},
921
};
1022

1123
export default function LifePage() {
12-
const lifePosts = getSortedFeedData().filter((post) => post.category === 'Life');
24+
const lifePosts = getSortedFeedData().filter(
25+
(post) => post.category === 'Life'
26+
);
1327

1428
return (
1529
<main className="py-10">

blog/ui/pages/SeriesPage.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,22 @@ import { Container } from '@/ui/layout';
44
import { EmptyState } from '@/ui';
55
import { getSeriesSummaries } from '@/blog/model/series-group';
66
import { SeriesHubList } from '@/blog/ui/components';
7+
import { createSiteUrl } from '@/site/config/site';
8+
9+
const seriesUrl = createSiteUrl('/series');
10+
const description = '연속된 학습과 구현 기록을 시리즈 단위로 모아봅니다';
711

812
export const metadata: Metadata = {
913
title: 'Series',
10-
description: '연속된 학습과 구현 기록을 시리즈 단위로 모아봅니다',
14+
description,
15+
alternates: {
16+
canonical: seriesUrl,
17+
},
18+
openGraph: {
19+
title: 'Series',
20+
description,
21+
url: seriesUrl,
22+
},
1123
};
1224

1325
export default function SeriesPage() {

resume/ui/pages/ResumePage.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,22 @@ import {
1313
} from '@/resume/model/resume-data';
1414
import { orderExperienceStages } from '@/resume/model/order-experience-stages';
1515
import type { Activity } from '@/resume/model/types';
16+
import { createSiteUrl } from '@/site/config/site';
17+
18+
const resumeUrl = createSiteUrl('/resume');
19+
const description = `${personalInfo.name}의 CV`;
1620

1721
export const metadata: Metadata = {
1822
title: 'CV',
19-
description: `${personalInfo.name}의 CV`,
23+
description,
24+
alternates: {
25+
canonical: resumeUrl,
26+
},
27+
openGraph: {
28+
title: 'CV',
29+
description,
30+
url: resumeUrl,
31+
},
2032
};
2133

2234
const heroStatement = '반복되는 운영을 데이터 구조와 자동화로 바꾸는 엔지니어';

site/config/site.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import {
44
SITE_DESCRIPTION,
55
SITE_KOREAN_NAME,
66
SITE_NAME,
7+
SITE_URL,
8+
createSiteUrl,
79
} from '@/site/config/site';
810
import packageJson from '@/package.json';
911

@@ -20,3 +22,11 @@ describe('site brand', () => {
2022
expect(SITE_DESCRIPTION).toContain(SITE_BRAND.koreanName);
2123
});
2224
});
25+
26+
describe('createSiteUrl', () => {
27+
it('returns canonical absolute URLs without a trailing slash for home', () => {
28+
expect(createSiteUrl()).toBe(SITE_URL);
29+
expect(createSiteUrl('/')).toBe(SITE_URL);
30+
expect(createSiteUrl('/blog/example')).toBe(`${SITE_URL}/blog/example`);
31+
});
32+
});

0 commit comments

Comments
 (0)