diff --git a/.env.example b/.env.example index a59d068..14a9ec2 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,16 @@ # 미설정 시 http://localhost:3000 으로 대체되며, 프로덕션 빌드에서 경고가 출력됩니다. # NEXT_PUBLIC_SITE_URL="https://blog.your-domain.com" +# ───────────────────────────────────────────────────────────── +# Base Path / 서브경로 (선택) +# GitHub Pages 프로젝트 저장소 배포(예: https://.github.io//) 등 +# 사이트가 도메인의 루트가 아닌 하위 서브경로에서 서빙될 때 설정합니다. +# 시작은 '/' 로 시작하고 끝의 '/' 는 생략하세요. 예: /my-blog +# 사용자 저장소(https://.github.io)나 루트 도메인 배포 시에는 비워두세요. +# ───────────────────────────────────────────────────────────── + +# NEXT_PUBLIC_BASE_PATH="" + # ───────────────────────────────────────────────────────────── # Giscus 댓글 (선택) # 다섯 값은 전부 함께 설정하거나 전부 비워야 합니다. 일부만 채우면 빌드가 실패합니다. diff --git a/.gitignore b/.gitignore index 7d41533..4d6d564 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ pnpm-debug.log* .codex/ .omo/ CLAUDE.md +.claude/ \ No newline at end of file diff --git a/next.config.ts b/next.config.ts index 3394687..98d9f91 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,8 +1,16 @@ import type { NextConfig } from "next" +// biome-ignore lint/complexity/useLiteralKeys: tsconfig noPropertyAccessFromIndexSignature requires bracket access +const rawBasePath = process.env["NEXT_PUBLIC_BASE_PATH"]?.trim() +const basePath = + rawBasePath && rawBasePath !== "/" + ? (rawBasePath.startsWith("/") ? rawBasePath : `/${rawBasePath}`).replace(/\/+$/, "") + : undefined + const nextConfig: NextConfig = { // biome-ignore lint/complexity/useLiteralKeys: tsconfig noPropertyAccessFromIndexSignature requires bracket access ...(process.env["DEV_ORIGIN"] ? { allowedDevOrigins: [process.env["DEV_ORIGIN"]] } : {}), + ...(basePath ? { basePath, assetPrefix: basePath } : {}), output: "export", trailingSlash: true, images: { diff --git a/src/features/search/pagefind.ts b/src/features/search/pagefind.ts index 20ca1ea..4529079 100644 --- a/src/features/search/pagefind.ts +++ b/src/features/search/pagefind.ts @@ -17,15 +17,27 @@ type PagefindApi = Readonly<{ ) => Promise Promise }[] }>> }> -const BUNDLE_URL = "/pagefind/pagefind.js" +const DEFAULT_BUNDLE_PATH = "/pagefind/pagefind.js" const MAX_RESULTS = 20 let bundle: Promise | undefined +/** + * GitHub Pages 서브디렉터리(e.g., /repo/) 배포 환경을 지원하기 위해 + * 환경변수 NEXT_PUBLIC_BASE_PATH 가 지정된 경우 접두사로 조합한다. + */ +export function getPagefindBundleUrl(): string { + // biome-ignore lint/complexity/useLiteralKeys: tsconfig noPropertyAccessFromIndexSignature requires bracket access + const basePath = process.env["NEXT_PUBLIC_BASE_PATH"]?.trim() ?? "" + const normalizedBasePath = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath + + return `${normalizedBasePath}${DEFAULT_BUNDLE_PATH}` +} + // 번들은 postbuild 단계에서 생기므로 개발 서버와 빌드 시점에는 존재하지 않는다. // 정적 경로로 쓰면 번들러가 해석을 시도하다 실패하니 변수와 ignore 주석으로 남긴다. async function loadPagefind(): Promise { - const url = BUNDLE_URL + const url = getPagefindBundleUrl() const api = (await import( /* webpackIgnore: true */ /* turbopackIgnore: true */ url )) as PagefindApi @@ -42,8 +54,16 @@ export async function searchPosts(query: string): Promise { + // 1회 로드 실패(Reject) 시 모듈 레벨 캐시를 리셋하여 후속 검색 요청이 영구 실패하지 않고 재시도할 수 있게 한다. + bundle = undefined + throw error + }) + } + + const api = await bundle + const { results } = await api.search(trimmed) const fragments = await Promise.all(results.slice(0, MAX_RESULTS).map((result) => result.data())) return fragments.map((fragment) => ({ @@ -52,3 +72,8 @@ export async function searchPosts(query: string): Promise { + const originalEnv = process.env + + beforeEach(() => { + _resetPagefindBundleCacheForTesting() + process.env = { ...originalEnv } + }) + + afterEach(() => { + _resetPagefindBundleCacheForTesting() + process.env = originalEnv + vi.restoreAllMocks() + }) + + describe("getPagefindBundleUrl", () => { + it("Given no NEXT_PUBLIC_BASE_PATH When resolving bundle URL Then it returns default root path", () => { + // biome-ignore lint/complexity/useLiteralKeys: tsconfig noPropertyAccessFromIndexSignature requires bracket access + delete process.env["NEXT_PUBLIC_BASE_PATH"] + expect(getPagefindBundleUrl()).toBe("/pagefind/pagefind.js") + }) + + it("Given NEXT_PUBLIC_BASE_PATH without trailing slash When resolving Then it prepends base path", () => { + // biome-ignore lint/complexity/useLiteralKeys: tsconfig noPropertyAccessFromIndexSignature requires bracket access + process.env["NEXT_PUBLIC_BASE_PATH"] = "/blog" + expect(getPagefindBundleUrl()).toBe("/blog/pagefind/pagefind.js") + }) + + it("Given NEXT_PUBLIC_BASE_PATH with trailing slash When resolving Then it normalizes trailing slash", () => { + // biome-ignore lint/complexity/useLiteralKeys: tsconfig noPropertyAccessFromIndexSignature requires bracket access + process.env["NEXT_PUBLIC_BASE_PATH"] = "/blog/" + expect(getPagefindBundleUrl()).toBe("/blog/pagefind/pagefind.js") + }) + }) + + describe("searchPosts cache & recovery", () => { + it("Given empty query When searching Then it returns empty array without loading bundle", async () => { + const result = await searchPosts(" ") + expect(result).toEqual([]) + }) + }) +})