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
43 changes: 16 additions & 27 deletions src/app/manifest.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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",
Expand All @@ -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" }],
},
],
}
Expand Down
7 changes: 2 additions & 5 deletions src/components/markdown-content.tsx
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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(
Expand Down
8 changes: 3 additions & 5 deletions src/features/pwa/pwa-register.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useEffect } from "react"
import { toast } from "sonner"
import { prefixBasePath } from "@/lib/base-path"

export function PwaRegister() {
useEffect(() => {
Expand All @@ -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 })
Expand Down
8 changes: 3 additions & 5 deletions src/features/search/pagefind.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { prefixBasePath } from "@/lib/base-path"

export type SearchResult = Readonly<{
url: string
title: string
Expand Down Expand Up @@ -27,11 +29,7 @@ let bundle: Promise<PagefindApi> | 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 단계에서 생기므로 개발 서버와 빌드 시점에는 존재하지 않는다.
Expand Down
39 changes: 39 additions & 0 deletions src/lib/base-path.ts
Original file line number Diff line number Diff line change
@@ -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}`
}
85 changes: 85 additions & 0 deletions tests/base-path.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
})