diff --git a/src/app/manifest.ts b/src/app/manifest.ts index 8945f30..a61d2f0 100644 --- a/src/app/manifest.ts +++ b/src/app/manifest.ts @@ -1,29 +1,18 @@ import type { MetadataRoute } from "next" import { siteConfig, themeColors } from "@/config/site" +import { prefixBasePath } from "@/lib/base-path" export const dynamic = "force-static" -function getBasePath(): string { - // biome-ignore lint/complexity/useLiteralKeys: tsconfig noPropertyAccessFromIndexSignature requires bracket access - const rawBasePath = process.env["NEXT_PUBLIC_BASE_PATH"]?.trim() - - return rawBasePath && rawBasePath !== "/" - ? (rawBasePath.startsWith("/") ? rawBasePath : `/${rawBasePath}`).replace(/\/+$/, "") - : "" -} - // biome-ignore lint/style/noDefaultExport: Next.js metadata files require default exports. export default function manifest(): MetadataRoute.Manifest { - const basePath = getBasePath() - const prefix = (path: string) => `${basePath}${path}` - return { name: siteConfig.name, short_name: siteConfig.name, description: siteConfig.description, - id: prefix("/"), - start_url: prefix("/"), - scope: prefix("/"), + id: prefixBasePath("/"), + start_url: prefixBasePath("/"), + scope: prefixBasePath("/"), display: "standalone", orientation: "portrait", lang: siteConfig.language, @@ -33,25 +22,25 @@ export default function manifest(): MetadataRoute.Manifest { theme_color: themeColors.dark, icons: [ { - src: prefix("/icons/icon-192.png"), + src: prefixBasePath("/icons/icon-192.png"), sizes: "192x192", type: "image/png", purpose: "any", }, { - src: prefix("/icons/icon-192.png"), + src: prefixBasePath("/icons/icon-192.png"), sizes: "192x192", type: "image/png", purpose: "maskable", }, { - src: prefix("/icons/icon-512.png"), + src: prefixBasePath("/icons/icon-512.png"), sizes: "512x512", type: "image/png", purpose: "any", }, { - src: prefix("/icons/icon-512.png"), + src: prefixBasePath("/icons/icon-512.png"), sizes: "512x512", type: "image/png", purpose: "maskable", @@ -62,29 +51,29 @@ export default function manifest(): MetadataRoute.Manifest { name: "전체 글 목록", short_name: "글 목록", description: "최신 기술 블로그 포스트 목록을 확인합니다.", - url: prefix("/posts/"), - icons: [{ src: prefix("/icons/icon-192.png"), sizes: "192x192" }], + url: prefixBasePath("/posts/"), + icons: [{ src: prefixBasePath("/icons/icon-192.png"), sizes: "192x192" }], }, { name: "카테고리", short_name: "카테고리", description: "주제별 분류 목록을 확인합니다.", - url: prefix("/categories/"), - icons: [{ src: prefix("/icons/icon-192.png"), sizes: "192x192" }], + url: prefixBasePath("/categories/"), + icons: [{ src: prefixBasePath("/icons/icon-192.png"), sizes: "192x192" }], }, { name: "태그 목록", short_name: "태그", description: "관심 태그별 글을 탐색합니다.", - url: prefix("/tags/"), - icons: [{ src: prefix("/icons/icon-192.png"), sizes: "192x192" }], + url: prefixBasePath("/tags/"), + icons: [{ src: prefixBasePath("/icons/icon-192.png"), sizes: "192x192" }], }, { name: "검색", short_name: "검색", description: "블로그 콘텐츠를 빠르게 검색합니다.", - url: prefix("/search/"), - icons: [{ src: prefix("/icons/icon-192.png"), sizes: "192x192" }], + url: prefixBasePath("/search/"), + icons: [{ src: prefixBasePath("/icons/icon-192.png"), sizes: "192x192" }], }, ], } diff --git a/src/components/markdown-content.tsx b/src/components/markdown-content.tsx index 773098b..feadbe1 100644 --- a/src/components/markdown-content.tsx +++ b/src/components/markdown-content.tsx @@ -1,4 +1,5 @@ import { PostDiagramRenderer } from "@/features/post-diagram/post-diagram-renderer" +import { prefixBasePath } from "@/lib/base-path" import type { SanitizedHtml } from "@/lib/markdown" import { cn } from "@/lib/utils" @@ -16,11 +17,7 @@ const DEFAULT_KATEX_CSS_PATH = "/katex/katex.min.css" * 환경변수 NEXT_PUBLIC_BASE_PATH 가 지정된 경우 KaTeX 스타일시트 경로 앞에 접두사로 조합합니다. */ export function getKatexStylesheetUrl(): 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_KATEX_CSS_PATH}` + return prefixBasePath(DEFAULT_KATEX_CSS_PATH) } const markdownContentClassName = cn( diff --git a/src/features/pwa/pwa-register.tsx b/src/features/pwa/pwa-register.tsx index 2b4787b..15ec0e5 100644 --- a/src/features/pwa/pwa-register.tsx +++ b/src/features/pwa/pwa-register.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react" import { toast } from "sonner" +import { prefixBasePath } from "@/lib/base-path" export function PwaRegister() { useEffect(() => { @@ -11,11 +12,8 @@ export function PwaRegister() { // LCP 및 초기 렌더링 성능 저하를 방지하기 위해 load 이벤트 이후 지연 등록 const handleLoad = async () => { - // 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 - const swUrl = `${normalizedBasePath}/sw.js` - const scope = `${normalizedBasePath}/` + const swUrl = prefixBasePath("/sw.js") + const scope = prefixBasePath("/") try { const registration = await navigator.serviceWorker.register(swUrl, { scope }) diff --git a/src/features/search/pagefind.ts b/src/features/search/pagefind.ts index 4529079..be2ba91 100644 --- a/src/features/search/pagefind.ts +++ b/src/features/search/pagefind.ts @@ -1,3 +1,5 @@ +import { prefixBasePath } from "@/lib/base-path" + export type SearchResult = Readonly<{ url: string title: string @@ -27,11 +29,7 @@ let bundle: Promise | undefined * 환경변수 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}` + return prefixBasePath(DEFAULT_BUNDLE_PATH) } // 번들은 postbuild 단계에서 생기므로 개발 서버와 빌드 시점에는 존재하지 않는다. diff --git a/src/lib/base-path.ts b/src/lib/base-path.ts new file mode 100644 index 0000000..5f34150 --- /dev/null +++ b/src/lib/base-path.ts @@ -0,0 +1,39 @@ +// NEXT_PUBLIC_BASE_PATH 환경변수를 읽어 정규화된 basePath 를 반환한다. +// 이 로직이 manifest.ts, markdown-content.tsx, pagefind.ts, pwa-register.tsx 에 +// 각각 복제되어 있던 것을 단일 모듈로 통합한다. +// +// next.config.ts 는 src/ 외부에 있어 @/ import 가 불가능하므로, +// 거기에 남아 있는 정규화는 이 함수와 규칙이 반드시 일치해야 한다. + +/** + * `NEXT_PUBLIC_BASE_PATH` 를 읽어 선행 슬래시를 보장하고 후행 슬래시를 제거한 경로를 반환한다. + * 미설정이거나 `"/"` 이면 빈 문자열을 반환한다. + */ +export function getBasePath(): string { + // biome-ignore lint/complexity/useLiteralKeys: tsconfig noPropertyAccessFromIndexSignature requires bracket access + const raw = process.env["NEXT_PUBLIC_BASE_PATH"]?.trim() + + if (!raw || raw === "/") { + return "" + } + + const withLeadingSlash = raw.startsWith("/") ? raw : `/${raw}` + + return withLeadingSlash.replace(/\/+$/, "") +} + +/** + * 정규화된 basePath 를 주어진 절대 경로 앞에 붙인다. + * `path` 는 `"/"` 로 시작해야 한다. + * + * @example + * // NEXT_PUBLIC_BASE_PATH="/repo" + * prefixBasePath("/pagefind/pagefind.js") // → "/repo/pagefind/pagefind.js" + * + * @example + * // NEXT_PUBLIC_BASE_PATH 미설정 + * prefixBasePath("/sw.js") // → "/sw.js" + */ +export function prefixBasePath(path: string): string { + return `${getBasePath()}${path}` +} diff --git a/tests/base-path.test.ts b/tests/base-path.test.ts new file mode 100644 index 0000000..e7eaf79 --- /dev/null +++ b/tests/base-path.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { getBasePath, prefixBasePath } from "../src/lib/base-path" + +describe("base-path utility", () => { + const originalEnv = process.env + + beforeEach(() => { + process.env = { ...originalEnv } + }) + + afterEach(() => { + process.env = originalEnv + }) + + describe("getBasePath", () => { + it("Given undefined NEXT_PUBLIC_BASE_PATH When getting basePath Then returns empty string", () => { + // biome-ignore lint/complexity/useLiteralKeys: tsconfig noPropertyAccessFromIndexSignature requires bracket access + delete process.env["NEXT_PUBLIC_BASE_PATH"] + expect(getBasePath()).toBe("") + }) + + it("Given empty or whitespace NEXT_PUBLIC_BASE_PATH When getting basePath Then returns empty string", () => { + // biome-ignore lint/complexity/useLiteralKeys: tsconfig noPropertyAccessFromIndexSignature requires bracket access + process.env["NEXT_PUBLIC_BASE_PATH"] = " " + expect(getBasePath()).toBe("") + }) + + it("Given root slash '/' NEXT_PUBLIC_BASE_PATH When getting basePath Then returns empty string", () => { + // biome-ignore lint/complexity/useLiteralKeys: tsconfig noPropertyAccessFromIndexSignature requires bracket access + process.env["NEXT_PUBLIC_BASE_PATH"] = "/" + expect(getBasePath()).toBe("") + }) + + it("Given single segment with leading slash When getting basePath Then returns normalized basePath", () => { + // biome-ignore lint/complexity/useLiteralKeys: tsconfig noPropertyAccessFromIndexSignature requires bracket access + process.env["NEXT_PUBLIC_BASE_PATH"] = "/repo" + expect(getBasePath()).toBe("/repo") + }) + + it("Given single segment with trailing slash When getting basePath Then removes trailing slashes", () => { + // biome-ignore lint/complexity/useLiteralKeys: tsconfig noPropertyAccessFromIndexSignature requires bracket access + process.env["NEXT_PUBLIC_BASE_PATH"] = "/repo/" + expect(getBasePath()).toBe("/repo") + + // biome-ignore lint/complexity/useLiteralKeys: tsconfig noPropertyAccessFromIndexSignature requires bracket access + process.env["NEXT_PUBLIC_BASE_PATH"] = "/repo///" + expect(getBasePath()).toBe("/repo") + }) + + it("Given single segment without leading slash When getting basePath Then prepends leading slash", () => { + // biome-ignore lint/complexity/useLiteralKeys: tsconfig noPropertyAccessFromIndexSignature requires bracket access + process.env["NEXT_PUBLIC_BASE_PATH"] = "repo" + expect(getBasePath()).toBe("/repo") + + // biome-ignore lint/complexity/useLiteralKeys: tsconfig noPropertyAccessFromIndexSignature requires bracket access + process.env["NEXT_PUBLIC_BASE_PATH"] = "repo/" + expect(getBasePath()).toBe("/repo") + }) + + it("Given nested path When getting basePath Then preserves inner path and normalizes boundaries", () => { + // biome-ignore lint/complexity/useLiteralKeys: tsconfig noPropertyAccessFromIndexSignature requires bracket access + process.env["NEXT_PUBLIC_BASE_PATH"] = "nested/sub-repo/" + expect(getBasePath()).toBe("/nested/sub-repo") + }) + }) + + describe("prefixBasePath", () => { + it("Given no basePath When prefixing path Then returns original path unchanged", () => { + // biome-ignore lint/complexity/useLiteralKeys: tsconfig noPropertyAccessFromIndexSignature requires bracket access + delete process.env["NEXT_PUBLIC_BASE_PATH"] + expect(prefixBasePath("/")).toBe("/") + expect(prefixBasePath("/pagefind/pagefind.js")).toBe("/pagefind/pagefind.js") + expect(prefixBasePath("/katex/katex.min.css")).toBe("/katex/katex.min.css") + }) + + it("Given basePath When prefixing path Then combines basePath and target path", () => { + // biome-ignore lint/complexity/useLiteralKeys: tsconfig noPropertyAccessFromIndexSignature requires bracket access + process.env["NEXT_PUBLIC_BASE_PATH"] = "/my-blog/" + expect(prefixBasePath("/")).toBe("/my-blog/") + expect(prefixBasePath("/pagefind/pagefind.js")).toBe("/my-blog/pagefind/pagefind.js") + expect(prefixBasePath("/katex/katex.min.css")).toBe("/my-blog/katex/katex.min.css") + expect(prefixBasePath("/icons/icon-192.png")).toBe("/my-blog/icons/icon-192.png") + }) + }) +})