+ {/* 글 공유: 정식 절대 URL을 넘겨 localhost가 아닌 canonical 링크가 공유되게 한다. */}
+
+
+
{post.prevPost ? (
) : (
@@ -223,7 +228,7 @@ export function PostDetail({ children, series, post }: PostDetailProps) {
)}
-
+
{/* === 댓글 (giscus · GitHub Discussions 기반, DB 없음) === */}
diff --git a/apps/blog/src/components/post-detail/post-recommendation.tsx b/apps/blog/src/components/post-detail/post-recommendation.tsx
index 5e497f2..19dd512 100644
--- a/apps/blog/src/components/post-detail/post-recommendation.tsx
+++ b/apps/blog/src/components/post-detail/post-recommendation.tsx
@@ -1,20 +1,27 @@
'use client';
-import { Constants } from '@libs/constants';
import { MainLayoutContext } from 'src/app/(main)/main-client-layout';
+import { recommendPosts } from '@libs/recommend-posts';
+import { PostModel } from '@libs/types/commons';
import Link from 'next/link';
import { useContext, useMemo } from 'react';
-export function PostRecommendation() {
+export interface PostRecommendationProps {
+ // 현재 보고 있는 글. 이 글과의 유사도로 추천 목록을 만든다.
+ currentPost: PostModel;
+}
+
+export function PostRecommendation({ currentPost }: PostRecommendationProps) {
+ // 전체 글 목록은 레이아웃 컨텍스트에 이미 있어 추가 fetch가 필요 없다.
const { posts } = useContext(MainLayoutContext);
- const recommendedPosts = useMemo(() => {
- const ids = Constants.recommendation.postIds;
- return ids
- .map((id) => posts.find((p) => p.id === id))
- .filter((p): p is NonNullable
=> p !== undefined);
- }, [posts]);
+ // 태그 겹침 + 같은 시리즈 가중치로 상위 4개를 산출한다(현재 글 제외).
+ const recommendedPosts = useMemo(
+ () => recommendPosts(currentPost, posts, 4),
+ [currentPost, posts],
+ );
+ // 접점 있는 글이 하나도 없으면 섹션 자체를 숨긴다.
if (recommendedPosts.length === 0) {
return null;
}
diff --git a/apps/blog/src/components/post-detail/share-buttons.test.tsx b/apps/blog/src/components/post-detail/share-buttons.test.tsx
new file mode 100644
index 0000000..2bcffdd
--- /dev/null
+++ b/apps/blog/src/components/post-detail/share-buttons.test.tsx
@@ -0,0 +1,53 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { ShareButtons } from './share-buttons';
+
+const url = 'https://blog.malloc72p.com/posts/frontend/my-post';
+const title = '내 포스트 제목';
+
+describe('ShareButtons', () => {
+ const writeText = jest.fn();
+
+ beforeEach(() => {
+ writeText.mockReset().mockResolvedValue(undefined);
+ Object.assign(navigator, { clipboard: { writeText } });
+ // 기본 환경(jsdom)에는 navigator.share가 없다고 가정한다.
+ Object.assign(navigator, { share: undefined });
+ });
+
+ it('링크 복사 버튼과 X 공유 링크(인텐트 URL)를 렌더한다', () => {
+ render();
+
+ expect(screen.getByRole('button', { name: '링크 복사' })).toBeInTheDocument();
+
+ const xLink = screen.getByRole('link', { name: 'X(트위터)에 공유' });
+ expect(xLink).toHaveAttribute(
+ 'href',
+ `https://twitter.com/intent/tweet?text=${encodeURIComponent(title)}&url=${encodeURIComponent(url)}`,
+ );
+ // 새 탭 + 안전한 rel을 보장한다.
+ expect(xLink).toHaveAttribute('target', '_blank');
+ expect(xLink).toHaveAttribute('rel', 'noopener noreferrer');
+ });
+
+ it('링크 복사 클릭 시 canonical URL을 클립보드에 쓰고 "복사됨"으로 피드백한다', async () => {
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: '링크 복사' }));
+
+ expect(writeText).toHaveBeenCalledWith(url);
+ expect(await screen.findByRole('button', { name: '링크 복사됨' })).toBeInTheDocument();
+ });
+
+ it('navigator.share 미지원 환경에서는 네이티브 공유 버튼을 노출하지 않는다', () => {
+ render();
+
+ expect(screen.queryByRole('button', { name: '공유하기' })).not.toBeInTheDocument();
+ });
+
+ it('navigator.share 지원 환경에서는 네이티브 공유 버튼을 노출한다', () => {
+ Object.assign(navigator, { share: jest.fn().mockResolvedValue(undefined) });
+ render();
+
+ expect(screen.getByRole('button', { name: '공유하기' })).toBeInTheDocument();
+ });
+});
diff --git a/apps/blog/src/components/post-detail/share-buttons.tsx b/apps/blog/src/components/post-detail/share-buttons.tsx
new file mode 100644
index 0000000..2ed4f40
--- /dev/null
+++ b/apps/blog/src/components/post-detail/share-buttons.tsx
@@ -0,0 +1,92 @@
+'use client';
+
+import { IconBrandX, IconCheck, IconLink, IconShare } from '@tabler/icons-react';
+import classNames from 'classnames';
+import { useEffect, useState } from 'react';
+
+export interface ShareButtonsProps {
+ // 공유할 글의 정식(canonical) 절대 URL.
+ url: string;
+ // 공유 텍스트로 사용할 글 제목.
+ title: string;
+}
+
+// 공통 버튼 스타일: 터치 타겟(약 44px) + 포커스 링.
+const buttonClass = classNames(
+ 'flex items-center justify-center p-2.5 rounded-md text-gray-500',
+ 'hover:text-black hover:bg-gray-100 active:bg-gray-200',
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400',
+);
+
+export function ShareButtons({ url, title }: ShareButtonsProps) {
+ // 링크 복사 성공 피드백 상태.
+ const [copied, setCopied] = useState(false);
+ // navigator.share 지원 여부. SSR과 마크업이 어긋나지 않도록 마운트 후에만 반영한다.
+ const [canNativeShare, setCanNativeShare] = useState(false);
+
+ useEffect(() => {
+ setCanNativeShare(typeof navigator !== 'undefined' && typeof navigator.share === 'function');
+ }, []);
+
+ const copyLink = async () => {
+ try {
+ await navigator.clipboard.writeText(url);
+ setCopied(true);
+ // 1.5초 뒤 원래 아이콘으로 되돌린다.
+ setTimeout(() => setCopied(false), 1500);
+ } catch {
+ // 클립보드 접근이 거부된 환경에서는 조용히 무시한다.
+ }
+ };
+
+ const nativeShare = async () => {
+ try {
+ await navigator.share({ title, url });
+ } catch {
+ // 사용자가 공유 시트를 취소하면 무시한다.
+ }
+ };
+
+ // X(트위터) 공유 인텐트 URL. 제목과 링크를 쿼리로 안전하게 인코딩한다.
+ const xShareUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(
+ title,
+ )}&url=${encodeURIComponent(url)}`;
+
+ return (
+
+
이 글 공유하기
+
+ {/* 링크 복사 */}
+
+
+ {/* X(트위터) 공유 */}
+
+
+
+
+ {/* 모바일 등 지원 환경에서만 OS 네이티브 공유 시트를 띄운다. */}
+ {canNativeShare && (
+
+ )}
+
+ );
+}
diff --git a/apps/blog/src/libs/api/load-layout-data.ts b/apps/blog/src/libs/api/load-layout-data.ts
new file mode 100644
index 0000000..8171ae5
--- /dev/null
+++ b/apps/blog/src/libs/api/load-layout-data.ts
@@ -0,0 +1,27 @@
+import { findPosts } from './find-posts';
+import { findSeriesList } from './find-series';
+import { findTags } from './find-tags';
+import { Mapper } from '../mapper';
+import { PostModel, SeriesModel, TagModel } from '../types/commons';
+
+export interface MainLayoutData {
+ seriesModels: SeriesModel[];
+ tags: TagModel[];
+ // 시리즈 미존재로 스킵된 글을 제거한 PostModel 목록(정렬은 호출 측에서 필요에 맞게).
+ posts: PostModel[];
+}
+
+/**
+ * (main) 레이아웃과 커스텀 404 등에서 공통으로 쓰는 시리즈/태그/글 데이터를 로드한다.
+ * 데이터 소스·매핑을 한 곳에 모아 중복을 없앤다.
+ */
+export async function loadMainLayoutData(): Promise {
+ const seriesModels = (await findSeriesList()).map(Mapper.toSeriesModel);
+ const tags = (await findTags()).map(Mapper.toTagModel);
+ const posts = (await findPosts())
+ .map((item) => Mapper.toPostModel({ item, seriesModels }))
+ // 시리즈 미존재로 스킵된 포스트(null)를 제거해 PostModel[]로 좁힌다.
+ .filter((post): post is PostModel => post !== null);
+
+ return { seriesModels, tags, posts };
+}
diff --git a/apps/blog/src/libs/constants.test.ts b/apps/blog/src/libs/constants.test.ts
deleted file mode 100644
index 255110f..0000000
--- a/apps/blog/src/libs/constants.test.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { Constants } from '@libs/constants';
-import { getAllMdxFiles } from '@libs/api/mdx-utils';
-
-// 다른 api 테스트와 달리 getAllMdxFiles를 mock하지 않는다.
-// 추천 ID 오타(예: 'next-grid-app-1')처럼 실제 글과의 불일치를 잡아야 하므로
-// 실제 posts 디렉토리를 스캔한 결과(진짜 postId 집합)와 대조한다.
-describe('Constants.recommendation', () => {
- it('모든 추천 postId가 실제 존재하는 글의 postId와 일치한다', async () => {
- // 실제 page.mdx들을 스캔해 유효한 postId 집합을 만든다(frontMatter.id = postId).
- const posts = await getAllMdxFiles();
- const existingIds = new Set(
- posts
- .map((post) => post.frontMatter.id)
- .filter((id): id is string => typeof id === 'string'),
- );
-
- // 실제 집합에 없는 추천 ID만 추려 오타/누락을 구체적으로 드러낸다.
- const missingIds = Constants.recommendation.postIds.filter((id) => !existingIds.has(id));
-
- expect(missingIds).toEqual([]);
- });
-});
diff --git a/apps/blog/src/libs/constants.ts b/apps/blog/src/libs/constants.ts
index 2b0d4a0..af2c38f 100644
--- a/apps/blog/src/libs/constants.ts
+++ b/apps/blog/src/libs/constants.ts
@@ -6,9 +6,6 @@ export const Constants = {
// skip 링크(본문 바로가기)와 타깃을 잇는 앵커 id. 두 파일이 같은 값을 공유하도록 단일화한다.
mainContentId: 'main-content',
},
- recommendation: {
- postIds: ['ai-tool', 'sse-exam', 'nextjs-grid-app-1', 'closure', 'execution-context'] as string[],
- },
siteConfig: {
name: 'Malloc72p.TechBlog',
title: 'Malloc72P의 기술블로그',
diff --git a/apps/blog/src/libs/recommend-posts.test.ts b/apps/blog/src/libs/recommend-posts.test.ts
new file mode 100644
index 0000000..c43d5f9
--- /dev/null
+++ b/apps/blog/src/libs/recommend-posts.test.ts
@@ -0,0 +1,87 @@
+import { recommendPosts } from './recommend-posts';
+import { PostModel, SeriesModel } from './types/commons';
+
+const frontend: SeriesModel = { id: 'frontend', title: 'Frontend', date: '2026-01-01' };
+const ai: SeriesModel = { id: 'ai', title: 'AI', date: '2026-01-01' };
+
+// 테스트용 PostModel 팩토리.
+function post(
+ id: string,
+ series: SeriesModel,
+ tagIds: string[],
+ date = '2026-01-01 00:00',
+): PostModel {
+ return {
+ id,
+ route: `/posts/${series.id}/${id}`,
+ series,
+ title: id,
+ tags: tagIds.map((t) => ({ id: t })),
+ date,
+ };
+}
+
+describe('recommendPosts', () => {
+ const current = post('current', frontend, ['react', 'ts']);
+
+ it('현재 글은 추천에서 제외한다', () => {
+ const all = [current, post('a', frontend, ['react'])];
+ const result = recommendPosts(current, all);
+
+ expect(result.map((p) => p.id)).not.toContain('current');
+ });
+
+ it('태그 겹침이 많을수록 더 높은 순위로 추천한다', () => {
+ const all = [
+ current,
+ post('twoTags', ai, ['react', 'ts']), // 태그 2개 겹침(다른 시리즈)
+ post('oneTag', ai, ['react', 'css']), // 태그 1개 겹침(다른 시리즈)
+ ];
+
+ const result = recommendPosts(current, all);
+
+ expect(result.map((p) => p.id)).toEqual(['twoTags', 'oneTag']);
+ });
+
+ it('태그 접점이 없어도 같은 시리즈면 추천한다', () => {
+ const all = [current, post('sameSeries', frontend, ['vue'])]; // 태그 0겹침, 같은 시리즈
+
+ const result = recommendPosts(current, all);
+
+ expect(result.map((p) => p.id)).toEqual(['sameSeries']);
+ });
+
+ it('태그도 안 겹치고 시리즈도 다르면 추천에서 제외한다', () => {
+ const all = [current, post('unrelated', ai, ['python'])];
+
+ const result = recommendPosts(current, all);
+
+ expect(result).toHaveLength(0);
+ });
+
+ it('limit 개수만큼만 추천한다', () => {
+ const all = [
+ current,
+ post('a', frontend, ['react']),
+ post('b', frontend, ['ts']),
+ post('c', frontend, ['react', 'ts']),
+ ];
+
+ const result = recommendPosts(current, all, 2);
+
+ expect(result).toHaveLength(2);
+ });
+
+ it('점수가 같으면 최신 글을 우선한다', () => {
+ const all = [
+ current,
+ post('older', ai, ['react'], '2026-01-01 00:00'),
+ post('newer', ai, ['react'], '2026-02-01 00:00'),
+ ];
+
+ const result = recommendPosts(current, all);
+
+ // 동일 점수(태그 1겹침, 다른 시리즈)이므로 날짜가 최신인 글이 먼저 와야 한다.
+ expect(result.map((p) => p.id)).toEqual(['newer', 'older']);
+ });
+});
diff --git a/apps/blog/src/libs/recommend-posts.ts b/apps/blog/src/libs/recommend-posts.ts
new file mode 100644
index 0000000..4008f8d
--- /dev/null
+++ b/apps/blog/src/libs/recommend-posts.ts
@@ -0,0 +1,46 @@
+import { PostModel } from './types/commons';
+
+// 같은 시리즈일 때 더해주는 가중치. 태그 자카드(0~1)와 합산되므로,
+// 태그 접점이 약해도 같은 시리즈 글이 우선 추천되도록 0.5로 둔다.
+const SAME_SERIES_BOOST = 0.5;
+
+/**
+ * 현재 글과의 유사도 점수.
+ * - 태그 자카드 유사도(교집합/합집합, 0~1)
+ * - 같은 시리즈면 가중치 가산
+ */
+function similarityScore(
+ current: PostModel,
+ currentTagIds: Set,
+ candidate: PostModel,
+): number {
+ const candidateTagIds = candidate.tags.map((tag) => tag.id);
+ // 두 글이 공유하는 태그 수(교집합).
+ const shared = candidateTagIds.filter((id) => currentTagIds.has(id)).length;
+ // 두 글 태그의 합집합 크기.
+ const union = new Set([...currentTagIds, ...candidateTagIds]).size;
+ // 합집합이 비면(둘 다 태그 없음) 자카드는 0으로 둔다.
+ const jaccard = union === 0 ? 0 : shared / union;
+ // 같은 시리즈면 가중치를 더한다.
+ const seriesBoost = candidate.series.id === current.series.id ? SAME_SERIES_BOOST : 0;
+
+ return jaccard + seriesBoost;
+}
+
+/**
+ * 현재 글과 태그/시리즈 유사도가 높은 글을 상위 limit개 추천한다.
+ * - 현재 글은 제외한다.
+ * - 접점이 전혀 없는 글(점수 0)은 제외해 무관한 추천을 막는다.
+ * - 동점은 최신 글을 우선한다.
+ */
+export function recommendPosts(current: PostModel, all: PostModel[], limit = 4): PostModel[] {
+ const currentTagIds = new Set(current.tags.map((tag) => tag.id));
+
+ return all
+ .filter((post) => post.id !== current.id) // 현재 글 제외
+ .map((post) => ({ post, score: similarityScore(current, currentTagIds, post) }))
+ .filter((scored) => scored.score > 0) // 접점 없는 글 제외
+ .sort((a, b) => b.score - a.score || b.post.date.localeCompare(a.post.date)) // 점수↓, 동점은 최신↑
+ .slice(0, limit)
+ .map((scored) => scored.post);
+}