Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@
# 미설정 시 http://localhost:3000 으로 대체되며, 프로덕션 빌드에서 경고가 출력됩니다.
# NEXT_PUBLIC_SITE_URL="https://blog.your-domain.com"

# ─────────────────────────────────────────────────────────────
# Base Path / 서브경로 (선택)
# GitHub Pages 프로젝트 저장소 배포(예: https://<user>.github.io/<repo>/) 등
# 사이트가 도메인의 루트가 아닌 하위 서브경로에서 서빙될 때 설정합니다.
# 시작은 '/' 로 시작하고 끝의 '/' 는 생략하세요. 예: /my-blog
# 사용자 저장소(https://<user>.github.io)나 루트 도메인 배포 시에는 비워두세요.
# ─────────────────────────────────────────────────────────────

# NEXT_PUBLIC_BASE_PATH=""

# ─────────────────────────────────────────────────────────────
# Giscus 댓글 (선택)
# 다섯 값은 전부 함께 설정하거나 전부 비워야 합니다. 일부만 채우면 빌드가 실패합니다.
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,4 @@ pnpm-debug.log*
.codex/
.omo/
CLAUDE.md
.claude/
8 changes: 8 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down
33 changes: 29 additions & 4 deletions src/features/search/pagefind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,27 @@ type PagefindApi = Readonly<{
) => Promise<Readonly<{ results: readonly { data: () => Promise<PagefindFragment> }[] }>>
}>

const BUNDLE_URL = "/pagefind/pagefind.js"
const DEFAULT_BUNDLE_PATH = "/pagefind/pagefind.js"
const MAX_RESULTS = 20

let bundle: Promise<PagefindApi> | 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<PagefindApi> {
const url = BUNDLE_URL
const url = getPagefindBundleUrl()
const api = (await import(
/* webpackIgnore: true */ /* turbopackIgnore: true */ url
)) as PagefindApi
Expand All @@ -42,8 +54,16 @@ export async function searchPosts(query: string): Promise<readonly SearchResult[
return []
}

bundle ??= loadPagefind()
const { results } = await (await bundle).search(trimmed)
if (bundle === undefined) {
bundle = loadPagefind().catch((error) => {
// 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) => ({
Expand All @@ -52,3 +72,8 @@ export async function searchPosts(query: string): Promise<readonly SearchResult[
excerpt: fragment.excerpt,
}))
}

/** 테스트 격리 및 번들 캐시 리셋 전용 함수 */
export function _resetPagefindBundleCacheForTesting(): void {
bundle = undefined
}
48 changes: 48 additions & 0 deletions tests/pagefind.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import {
_resetPagefindBundleCacheForTesting,
getPagefindBundleUrl,
searchPosts,
} from "../src/features/search/pagefind"

describe("pagefind module", () => {
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([])
})
})
})