From 1df8925fb946d633e78f02e5deb044ea2d24351d Mon Sep 17 00:00:00 2001 From: malloc72p Date: Mon, 29 Jun 2026 17:00:09 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(blog):=20=EC=BB=A4=EC=8A=A4=ED=85=80?= =?UTF-8?q?=20404=20+=20=EC=B6=94=EC=B2=9C=20=EA=B8=80=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=ED=99=94=20+=20=EA=B8=80=20=EA=B3=B5=EC=9C=A0=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20(#93,=20#89,=20#92)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #93 커스텀 404 - app/not-found.tsx: MainClientLayout 재사용으로 헤더/푸터/검색 확보, 홈·전체 태그·최근 글 복구 동선 제공 - e2e/not-found: 헤더와 복구 링크 단언 추가 #89 추천 글 자동화 - libs/recommend-posts.ts: 태그 자카드 + 같은 시리즈 가중치로 상위 N 산출(현재 글 제외) - PostRecommendation이 현재 글을 받아 동적 추천(하드코딩 postIds 제거) - 죽은 Constants.recommendation 및 전용 테스트 제거(동적 추천으로 대체) - recommend-posts.test: 제외/순위/시리즈 가중치/limit/동점 커버 #92 글 공유 버튼 - share-buttons.tsx: 링크 복사 + X(트위터) 공유 + 모바일 navigator.share - post 하단(footer)에 canonical URL로 배치 - share-buttons.test: 복사/인텐트 URL/네이티브 공유 노출 조건 커버 Co-Authored-By: Claude Opus 4.8 --- apps/blog/e2e/not-found.spec.ts | 8 +- apps/blog/src/app/not-found.tsx | 77 ++++++++++++++++ .../components/post-detail/post-detail.tsx | 9 +- .../post-detail/post-recommendation.tsx | 23 +++-- .../post-detail/share-buttons.test.tsx | 53 +++++++++++ .../components/post-detail/share-buttons.tsx | 92 +++++++++++++++++++ apps/blog/src/libs/constants.test.ts | 22 ----- apps/blog/src/libs/constants.ts | 3 - apps/blog/src/libs/recommend-posts.test.ts | 87 ++++++++++++++++++ apps/blog/src/libs/recommend-posts.ts | 46 ++++++++++ 10 files changed, 383 insertions(+), 37 deletions(-) create mode 100644 apps/blog/src/app/not-found.tsx create mode 100644 apps/blog/src/components/post-detail/share-buttons.test.tsx create mode 100644 apps/blog/src/components/post-detail/share-buttons.tsx delete mode 100644 apps/blog/src/libs/constants.test.ts create mode 100644 apps/blog/src/libs/recommend-posts.test.ts create mode 100644 apps/blog/src/libs/recommend-posts.ts diff --git a/apps/blog/e2e/not-found.spec.ts b/apps/blog/e2e/not-found.spec.ts index 1718fad..c827412 100644 --- a/apps/blog/e2e/not-found.spec.ts +++ b/apps/blog/e2e/not-found.spec.ts @@ -8,8 +8,12 @@ test.describe('존재하지 않는 경로', () => { // HTTP 응답 상태가 404여야 한다(가장 안정적인 계약). expect(response?.status()).toBe(404); - // 화면에 404가 노출되는지 확인한다. Next 내부 문구 전체에 의존하지 않도록 "404"만 검증한다. - // (커스텀 404는 별도 이슈 #93) + // 화면에 404가 노출되어야 한다. await expect(page.locator('body')).toContainText('404'); + + // 커스텀 404(#93): 헤더와 복구 동선(홈으로/전체 태그)이 포함되어야 한다. + await expect(page.locator('header.blog-main-header')).toBeVisible(); + await expect(page.getByRole('link', { name: '홈으로' })).toBeVisible(); + await expect(page.getByRole('link', { name: '전체 태그' })).toBeVisible(); }); }); diff --git a/apps/blog/src/app/not-found.tsx b/apps/blog/src/app/not-found.tsx new file mode 100644 index 0000000..ce42eea --- /dev/null +++ b/apps/blog/src/app/not-found.tsx @@ -0,0 +1,77 @@ +import { ArticleContainer } from '@components/article-container'; +import { findPosts } from '@libs/api/find-posts'; +import { findSeriesList } from '@libs/api/find-series'; +import { findTags } from '@libs/api/find-tags'; +import { Mapper } from '@libs/mapper'; +import { PageLinkMap } from '@libs/page-link-map'; +import { PostModel } from '@libs/types/commons'; +import Link from 'next/link'; +import MainClientLayout from 'src/app/(main)/main-client-layout'; + +/** + * 전역 커스텀 404 페이지. + * + * 헤더(검색 포함)·푸터를 그대로 쓰기 위해 (main) 레이아웃과 동일하게 데이터를 준비해 + * MainClientLayout으로 감싼다. 막다른 길에서 홈·태그·최근 글로 복귀할 동선을 제공한다. + */ +export default async function NotFound() { + 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); + + // 별도 인기도 데이터가 없어 최신 글 4개를 복구 동선으로 제시한다. + const recentPosts = [...posts].sort((a, b) => b.date.localeCompare(a.date)).slice(0, 4); + + return ( + + +
+

404

+ +
+

페이지를 찾을 수 없습니다.

+

주소가 바뀌었거나 삭제된 글일 수 있어요. 아래에서 다시 시작해 보세요.

+
+ + {/* 복구 링크 — 검색은 헤더의 검색 버튼으로 제공된다. */} +
+ + 홈으로 + + + 전체 태그 + +
+ + {/* 최근 글 바로가기 */} + {recentPosts.length > 0 && ( +
+

최근 글

+
    + {recentPosts.map((post) => ( +
  • + + {post.title} + +
  • + ))} +
+
+ )} +
+
+
+ ); +} diff --git a/apps/blog/src/components/post-detail/post-detail.tsx b/apps/blog/src/components/post-detail/post-detail.tsx index 7511e0d..9b5f610 100644 --- a/apps/blog/src/components/post-detail/post-detail.tsx +++ b/apps/blog/src/components/post-detail/post-detail.tsx @@ -4,6 +4,7 @@ import { ArticleHeader } from '@components/article'; import { ArticleContainer } from '@components/article-container'; import { Divider } from '@components/divider'; import { PostJsonLd } from '@components/post-json-ld'; +import { Constants } from '@libs/constants'; import { PostModel, SeriesModel, TagModel } from '@libs/types/commons'; import classNames from 'classnames'; import { PropsWithChildren, useEffect, useRef, useState } from 'react'; @@ -11,6 +12,7 @@ import classes from './post-detail.module.scss'; import { Toc, TocItem } from './toc'; import { PostNavigator, PostNavigatorPlaceholder } from './post-navigator'; import { PostRecommendation } from './post-recommendation'; +import { ShareButtons } from './share-buttons'; import { GiscusComments } from './giscus-comments'; export interface PostDetailProps extends PropsWithChildren { @@ -209,7 +211,10 @@ export function PostDetail({ children, series, post }: PostDetailProps) { {/* POST DETAIL Footer */} {/* ------------------------------------------------------ */}