diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 272da2f..d0b7b40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,4 +31,5 @@ jobs: - run: pnpm type-check - run: pnpm test:post-dates - run: pnpm build + - run: pnpm test:loading - run: pnpm test:sitemap-lastmod diff --git a/AGENTS.md b/AGENTS.md index 599b41f..f81ca26 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ React + TypeScript + Vite로 구축된 개인 기술 블로그. GitHub Pages 커 │ ├── main.tsx # 엔트리포인트 (ThemeProvider > LanguageProvider > App) │ ├── app-shell.tsx # SSR/CSR 공통 Provider shell │ ├── App.tsx # RouterProvider 생성 -│ ├── routes.tsx # 클라이언트 라우트 + 현재 경로 preload +│ ├── routes.tsx # 클라이언트 라우트 (lib/route-modules.ts에서 preload 공유) │ ├── routes.server.tsx # SSG용 정적 라우트 │ ├── entry-server.tsx # React SSR 렌더링 + prerender route metadata │ ├── layouts/ @@ -122,9 +122,11 @@ publishDate: "2025-12-01" # optional - 예약 발행 - 컬러 테마: `document.documentElement.dataset.color`로 관리 ### Post Data -- `content/posts/*.md` 파일을 `import.meta.glob`으로 eager 로드 +- `scripts/post-assets.ts`의 Vite 플러그인으로 목록 메타데이터(`?post-meta`)만 eager 로드 +- 글 본문(`?post-body`)은 필요할 때 로드하고, 본문 검색 인덱스는 검색 시 해당 언어만 로드 - `src/lib/posts.ts`에서 frontmatter 파싱, 정렬, 필터링, 검색 제공 -- draft/scheduled 글은 production 빌드에서 자동 필터링 +- `usePostContent`, `usePostSearchIndex`로 비동기 로드 상태와 데이터 갱신 구독 +- draft/scheduled 글은 production 메타데이터·본문·검색 인덱스에서 자동 필터링 ### SEO - `useMetaTags()` 훅으로 페이지별 title, description, OG 태그 설정 @@ -135,7 +137,9 @@ publishDate: "2025-12-01" # optional - 예약 발행 ### Hydrated SSG - 서버 렌더는 `src/routes.server.tsx`의 정적 라우트로 실제 React UI HTML을 생성 - 글 상세와 프로젝트 상세 경로는 빌드 시 개별 HTML로 프리렌더 -- 클라이언트는 `src/routes.tsx`에서 현재 경로 컴포넌트만 hydration 전에 preload하고, 나머지 라우트는 lazy 유지 +- `src/lib/route-modules.ts`에서 현재 경로 컴포넌트를 hydration 전에 preload하고, 메뉴 의도/idle 시 다른 라우트를 사전 로드 +- 글 상세는 빌드 시 본문을 준비하고 해당 글 하나의 JSON을 HTML에 넣어 hydration 시 본문을 다시 다운로드하지 않음 +- 분석 차트는 hydration 이후 로드해 차트 다운로드가 화면 전체 진입을 막지 않음 - `PostPage` 코드블록은 서버에서 안정적인 `
` fallback을 렌더하고 hydration 후 `CodeBlock`/Shiki를 로드
 - hydration 관련 변경 후에는 `pnpm build`, `pnpm preview`로 `Loading...`, Suspense hidden segment, React hydration warning이 없는지 확인
 
@@ -156,6 +160,7 @@ pnpm build      # 프로덕션 빌드 (tsc + client build + SSR build + hydrated
 pnpm preview    # 빌드 결과 미리보기
 pnpm lint       # ESLint 실행
 pnpm type-check # TypeScript 타입 체크
+pnpm test:loading # 빌드 후 로딩 분리, 재시도, SSG 회귀 검사
 ```
 
 ## Deployment
diff --git a/docs/navigation-performance.md b/docs/navigation-performance.md
new file mode 100644
index 0000000..53a4590
--- /dev/null
+++ b/docs/navigation-performance.md
@@ -0,0 +1,61 @@
+# 화면 전환 성능 개선과 검증 기준
+
+## 기준
+
+사용자가 확인한 첫 메뉴 이동 지연을 개선한다. 기존 레이아웃과 기능, 한·영 라우트, 검색 범위, 정적 HTML 본문을 유지한다. 차트 파일 수신 지연이 분석 페이지 전체 진입을 막지 않게 하고, 전환 요청의 진행·실패 상태를 알 수 있게 한다.
+
+## UI 계약
+
+| 대상 | 동작과 검증 기준 |
+|---|---|
+| 분석 화면 | 제목, 기간 선택, 요약과 글 목록은 차트 모듈을 기다리지 않고 표시한다. 차트 영역의 높이·순서는 유지한다. |
+| 차트 로딩 | 기존 Skeleton과 색상 토큰을 사용한다. 한국어 `차트를 불러오는 중…`, 영어 `Loading chart…`를 접근 가능한 상태로 제공한다. |
+| 차트 실패 | 차트 영역에 오류와 `다시 시도` / `Try again`을 표시한다. 메뉴 이동과 다른 데이터 조회는 계속 가능해야 한다. |
+| 사전 로딩 | 메뉴에 포인터·키보드 포커스가 오면 해당 화면을 준비한다. 요청은 중복하지 않고, 절전·느린 연결에서는 불필요한 백그라운드 다운로드를 줄인다. |
+| 전환 대기 | 상단 진행 표시와 스크린리더 상태를 제공한다. 빠른 전환에는 깜박임을 피한다. 오래 걸리면 해당 경로를 새로 열 수 있다. |
+| 전환 실패 | 새로 고침 재시도와 홈 이동을 제공한다. 자동 새로 고침 반복은 하지 않는다. |
+| 모바일 | 메뉴 닫힘 후 대상 화면 이동, 키보드·터치 동작, 390px 폭에서 가로 넘침을 확인한다. |
+| 모션 | 사이드바 메뉴는 스냅샷 전환 없이 즉시 갱신한다. 본문 링크의 짧은 전환 효과는 유지하고 reduced-motion에서는 새 진행 애니메이션을 끈다. |
+| 글 데이터 | 목록용 메타데이터와 본문 로드를 분리한다. 본문 검색, 번역 유무, draft·예약 발행 필터, 글 상세 SSG를 보존한다. |
+
+## 검증
+
+1. 타입·lint·기존 날짜/사이트맵/링크 검사를 실행한다.
+2. 로드 중복, 실패 후 재시도, 시간 초과를 단위 테스트한다.
+3. 프로덕션 빌드에서 분석 라우트의 초기 의존성에 차트 라이브러리가 없는지 확인한다.
+4. Chrome의 로컬 preview에서 차트 파일을 20초 지연시키고 그동안 제목·메뉴·본문이 동작하는지 확인한다.
+5. 라우트·차트 요청 실패, 복구, 연속 클릭, 뒤로가기, 한국어·영어, 모바일, reduced-motion을 확인한다.
+6. 초기 로드의 글 본문 요청 수와 정적 HTML의 글 본문·메타데이터를 비교한다.
+7. CDN은 실제 정상 요청의 Cache-Control·HIT/MISS·응답 시간을 읽기 전용으로 점검한다. 확인되지 않은 관리 권한이나 캐시 규칙 변경을 완료로 주장하지 않는다.
+
+## 구현 메모
+
+- 차트 모듈은 화면이 렌더링된 다음 불러온다. 30초 시간 초과는 같은 문서에서 재시도하고, 브라우저가 실패를 캐시하는 모듈 다운로드 오류는 현재 URL을 새로 열어 복구한다.
+- 글 상세의 정적 HTML에는 해당 글 하나의 hydration 데이터만 안전하게 직렬화한다. 직접 진입에서 본문을 다시 다운로드할 필요가 없고, 다른 글의 본문은 포함하지 않는다.
+- 본문 검색 인덱스는 검색이 필요할 때 해당 언어만 불러온다. 실패하면 제목·설명·태그 기준 결과임을 표시한다.
+- 운영 응답 점검: HTML `max-age=600`, Cloudflare `DYNAMIC`; 해시 자산 `max-age=14400`, `REVALIDATED`. 이 관측만으로 과거 21~28초 지연의 원인을 특정할 수 없으며 원격 캐시 정책은 변경하지 않았다.
+
+## 확인 결과 (2026-09-22)
+
+초기 HTML의 module script와 modulepreload JS 파일을 중복 없이 합산했다. 비교 기준은 변경 전 `1d45d565aff349f375c19e5f9d7a7bf3c68380a7`의 Vite 프로덕션 빌드이며, 압축 전 파일 크기다. 실제 네트워크 전송량이나 로딩 시간의 감소율로 해석하지 않는다.
+
+| 항목 | 변경 전 | 변경 후 |
+|---|---:|---:|
+| 초기 JS 파일 | 85 | 15 |
+| 초기 JS 크기 | 1,350,376 bytes | 579,536 bytes |
+
+초기 JS 크기는 57.1% 줄었다. 차트와 글 본문·검색 인덱스는 초기 정적 의존성에서 제외됐다.
+
+Chrome에서 로컬 production preview로 아래 시나리오를 확인했다.
+
+- 차트 파일 요청을 20초 지연: 분석 제목·요약·기간 선택이 표시되고, 7일 선택이 즉시 반영됐다. 요청을 풀면 차트 3개가 표시됐다.
+- 차트 실제 다운로드 실패 및 30초 시간 초과: 오류와 재시도를 표시하고 복구했다.
+- 메뉴 이동 및 첫 진입의 라우트 파일 실패: 오류 화면에서 마우스로 재시도해 소개 화면으로 복구했다.
+- 라우트 대기: 진행 표시, 5초 이후 지연 안내와 대상 경로 링크를 확인했다. 다른 메뉴로 이동하면 이전 진행 표시가 사라졌다.
+- 연속 메뉴 클릭: 분석 → 시리즈 → 소개 순서에서 마지막 대상인 소개로 이동했다.
+- 본문에만 있는 `Executors.newVirtualThreadPerTaskExecutor`로 한국어·영어 목록 검색과 빠른 검색 결과를 확인했다.
+- 글 상세 SPA 이동 시 본문과 목차를 표시했다. 직접 새로고침 시 해당 글의 hydration JSON을 사용하고 본문 JS를 추가 요청하지 않았다. 뒤로가기 시 검색어를 유지했다.
+- 영어 번역이 없는 글은 한국어 원문 링크를 안내했다.
+- 모바일 390px에서 메뉴를 닫고 글 목록으로 이동했다. 문서 폭도 390px로 가로 넘침이 없었다.
+- `prefers-reduced-motion: reduce`에서 진행 표시의 animation이 `none`이었다.
+- 빌드로 116개 정적 페이지를 생성했다. 로딩 테스트 7개, 날짜 테스트 6개, 사이트맵 테스트 6개와 내부 글 링크 검사가 통과했다. 타입 검사 통과, lint 오류 0개(기존 경고 16개).
diff --git a/package.json b/package.json
index bf1f14c..cb645dc 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,7 @@
     "type-check": "tsc --noEmit",
     "check:internal-links": "node scripts/check-internal-post-links.mjs",
     "test:post-dates": "node --test scripts/post-dates.test.mjs",
+    "test:loading": "node --test scripts/async-loader.test.mjs scripts/loading.test.mjs",
     "test:sitemap-lastmod": "node --test scripts/sitemap-lastmod.test.mjs"
   },
   "dependencies": {
diff --git a/scripts/async-loader.test.mjs b/scripts/async-loader.test.mjs
new file mode 100644
index 0000000..ce4e7d3
--- /dev/null
+++ b/scripts/async-loader.test.mjs
@@ -0,0 +1,37 @@
+import assert from "node:assert/strict"
+import test from "node:test"
+import { createRetryableLoader } from "../src/lib/async-loader.ts"
+
+test("prefetch and navigation share one request and reuse the result", async () => {
+  let calls = 0
+  let resolve
+  const load = createRetryableLoader(() => {
+    calls++
+    return new Promise((done) => { resolve = done })
+  })
+  const prefetch = load()
+  const navigation = load()
+  assert.equal(prefetch, navigation)
+  await Promise.resolve()
+  resolve("ready")
+  assert.equal(await navigation, "ready")
+  assert.equal(await load(), "ready")
+  assert.equal(calls, 1)
+})
+
+test("a rejected loader promise is cleared before retry", async () => {
+  let calls = 0
+  const load = createRetryableLoader(async () => {
+    if (++calls === 1) throw new Error("offline")
+    return "recovered"
+  })
+  await assert.rejects(load(), /offline/)
+  assert.equal(await load(), "recovered")
+})
+
+test("a stalled request times out and can be retried", async () => {
+  let calls = 0
+  const load = createRetryableLoader(() => ++calls === 1 ? new Promise(() => {}) : Promise.resolve("ready"), 20)
+  await assert.rejects(load(), /timed out/)
+  assert.equal(await load(), "ready")
+})
diff --git a/scripts/loading.test.mjs b/scripts/loading.test.mjs
new file mode 100644
index 0000000..969ba3c
--- /dev/null
+++ b/scripts/loading.test.mjs
@@ -0,0 +1,57 @@
+import assert from "node:assert/strict"
+import fs from "node:fs"
+import test from "node:test"
+import { isPublished, postMetadataSource } from "./post-assets.ts"
+import { parseFrontmatter } from "../src/lib/post-frontmatter.ts"
+
+test("metadata keeps headers and reading time without article content", () => {
+  const body = "본문에만있는검색어 ".repeat(450)
+  const raw = `---\ntitle: "A title"\ndate: "2026-09-01"\nupdated: "2026-09-02"\ntags: ["a"]\n---\n${body}`
+  const metadata = postMetadataSource(raw)
+  const parsed = parseFrontmatter(metadata)
+  assert.equal(parsed.data.title, "A title")
+  assert.deepEqual(parsed.data.tags, ["a"])
+  assert.equal(parsed.data.readingMinutes, "3")
+  assert.equal(parsed.content, "")
+  assert.ok(!metadata.includes("본문에만있는검색어"))
+})
+
+test("production indices exclude drafts and future posts", () => {
+  const raw = (extra) => `---\ntitle: "Post"\n${extra}\n---\ncontent`
+  assert.equal(isPublished(raw('draft: true'), "2026-09-22"), false)
+  assert.equal(isPublished(raw('draft: "true"'), "2026-09-22"), false)
+  assert.equal(isPublished(raw('publishDate: "2026-09-23"'), "2026-09-22"), false)
+  assert.equal(isPublished(raw('publishDate: "2026-09-22"'), "2026-09-22"), true)
+  assert.equal(isPublished(raw('draft: false'), "2026-09-22"), true)
+})
+
+const manifest = JSON.parse(fs.readFileSync(new URL("../dist/.vite/manifest.json", import.meta.url), "utf8"))
+function staticImports(key, visited = new Set()) {
+  if (visited.has(key)) return visited
+  assert.ok(manifest[key], `Missing build manifest entry: ${key}`)
+  visited.add(key)
+  for (const dependency of manifest[key].imports ?? []) staticImports(dependency, visited)
+  return visited
+}
+
+test("initial page and analytics shell do not wait for chart or body/search chunks", () => {
+  for (const entry of ["index.html", "src/pages/AnalyticsPage.tsx"]) {
+    const imports = [...staticImports(entry)]
+    assert.deepEqual(imports.filter((key) => /AnalyticsCharts|post-body|post-search|_chart-/.test(key)), [])
+  }
+  assert.ok(manifest["src/components/AnalyticsCharts.tsx"]?.isDynamicEntry)
+  assert.ok(manifest["virtual:post-search/ko"]?.isDynamicEntry)
+  assert.ok(manifest["virtual:post-search/en"]?.isDynamicEntry)
+})
+
+test("prerendered article keeps its body while analytics has stable chart placeholders", () => {
+  const article = fs.readFileSync(new URL("../dist/posts/spring-ai-cs-automation/index.html", import.meta.url), "utf8")
+  assert.match(article, /

today) +} + +export function postMetadataSource(raw: string) { + const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/) + if (!match) return raw + const { content } = parseFrontmatter(raw) + return `---\n${match[1]}\nreadingMinutes: ${getReadingMinutes(content)}\n---\n` +} + +export function postAssetsPlugin(): Plugin { + let root = "" + let production = false + return { + name: "post-metadata-and-search", + enforce: "pre", + configResolved(config) { root = config.root; production = config.isProduction }, + resolveId(id) { + if (/^virtual:post-search\/(ko|en)$/.test(id)) return `\0${id}` + }, + load(id) { + const language = id.match(/^\0virtual:post-search\/(ko|en)$/)?.[1] + const today = new Date().toISOString().slice(0, 10) + if (language) { + const folder = path.join(root, "content/posts", language) + const texts: Record = {} + for (const file of fs.readdirSync(folder).filter((file) => file.endsWith(".md"))) { + const filename = path.join(folder, file) + this.addWatchFile(filename) + const raw = fs.readFileSync(filename, "utf8") + if (!production || isPublished(raw, today)) texts[file.slice(0, -3)] = parseFrontmatter(raw).content.toLowerCase() + } + return `export default ${JSON.stringify(texts)}` + } + const [filename, query = ""] = id.split("?") + if (!filename?.endsWith(".md")) return + const params = new URLSearchParams(query) + if (!params.has("post-meta") && !params.has("post-body")) return + this.addWatchFile(filename) + const raw = fs.readFileSync(filename, "utf8") + const result = production && !isPublished(raw, today) ? "" : params.has("post-meta") ? postMetadataSource(raw) : raw + return `export default ${JSON.stringify(result)}` + }, + handleHotUpdate(context) { + if (!context.file.endsWith(".md")) return + for (const language of ["ko", "en"]) { + const module = context.server.moduleGraph.getModuleById(`\0virtual:post-search/${language}`) + if (module) context.server.moduleGraph.invalidateModule(module) + } + context.server.ws.send({ type: "full-reload" }) + }, + } +} diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs index f6b853a..6ad0526 100644 --- a/scripts/prerender.mjs +++ b/scripts/prerender.mjs @@ -1,7 +1,7 @@ import fs from "node:fs" import path from "node:path" import { fileURLToPath } from "node:url" -import { getPrerenderRoutes, render } from "../dist-ssr/entry-server.js" +import { getPrerenderRoutes, render, preparePostContentForPrerender, getPostHydrationData } from "../dist-ssr/entry-server.js" const __dirname = path.dirname(fileURLToPath(import.meta.url)) const rootDir = path.resolve(__dirname, "..") @@ -95,11 +95,16 @@ function injectAppHtml(templateHtml, appHtml) { ) } +await preparePostContentForPrerender() const routes = getPrerenderRoutes() for (const route of routes) { const appHtml = await render(route.path) - const html = injectAppHtml(withHead(template, route), appHtml) + let html = injectAppHtml(withHead(template, route), appHtml) + const postData = getPostHydrationData(route.path) + if (postData) { + html = html.replace("", `\n`) + } const outPath = outputPathFor(route.path) fs.mkdirSync(path.dirname(outPath), { recursive: true }) diff --git a/src/components/AnalyticsCharts.tsx b/src/components/AnalyticsCharts.tsx new file mode 100644 index 0000000..d06215a --- /dev/null +++ b/src/components/AnalyticsCharts.tsx @@ -0,0 +1,54 @@ +import { Bar, BarChart, CartesianGrid, Label, Pie, PieChart, XAxis, YAxis } from "recharts" +import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@/components/ui/chart" +import { useT } from "@/i18n" + +export { DailyVisitsChart } from "@/components/DailyVisitsChart" + +export function TagDistributionChart({ data }: { data: { tag: string; count: number; fill: string }[] }) { + const t = useT() + const config: ChartConfig = { count: { label: t.analytics.postsCount } } + for (const item of data) config[item.tag] = { label: item.tag, color: item.fill } + + return ( + + + } /> + + + + + ) +} + +export function MonthlyPostsChart({ data }: { data: { month: string; count: number }[] }) { + const t = useT() + const config = { count: { label: t.analytics.postsCount, color: "var(--primary)" } } satisfies ChartConfig + return ( + + + + { + const [year, month] = value.split("-") + return `${year!.slice(2)}/${month}` + }} tickLine={false} axisLine={false} tickMargin={8} minTickGap={30} /> + + } labelFormatter={(value) => value} /> + + + + ) +} diff --git a/src/components/ChartPlaceholder.tsx b/src/components/ChartPlaceholder.tsx new file mode 100644 index 0000000..a299b67 --- /dev/null +++ b/src/components/ChartPlaceholder.tsx @@ -0,0 +1,22 @@ +import { Button } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" +import { useT } from "@/i18n" + +export function ChartPlaceholder({ error, retry, height }: { error: boolean; retry: () => void; height: number }) { + const t = useT() + return ( +
+ {error ? ( + <> +

{t.common.chartLoadError}

+ + + ) : ( + <> + +

{t.common.chartLoading}

+ + )} +
+ ) +} diff --git a/src/components/LanguageSuggestionBanner.tsx b/src/components/LanguageSuggestionBanner.tsx index a7648e4..5fc25c5 100644 --- a/src/components/LanguageSuggestionBanner.tsx +++ b/src/components/LanguageSuggestionBanner.tsx @@ -3,6 +3,7 @@ import { useLocation, useNavigate } from "react-router-dom" import { Languages } from "lucide-react" import { Button } from "@/components/ui/button" import { en, ko, type Language } from "@/i18n/translations" +import { getPostAvailableLanguages } from "@/lib/posts" import { analytics } from "@/lib/analytics" import { detectSupportedBrowserLanguage, @@ -13,15 +14,6 @@ import { stripLanguagePrefix, } from "@/lib/i18n-routing" -const koreanPostFiles = import.meta.glob("/content/posts/ko/*.md", { - query: "?raw", - import: "default", -}) -const englishPostFiles = import.meta.glob("/content/posts/en/*.md", { - query: "?raw", - import: "default", -}) -const localizedPostFiles = { ko: koreanPostFiles, en: englishPostFiles } satisfies Record Promise>> const localizedStaticPaths = new Set(["/", "/posts", "/tags", "/series", "/analytics", "/about", "/privacy"]) function sessionDismissKey(source: Language, target: Language) { @@ -58,7 +50,7 @@ async function hasLanguageAlternative(pathname: string, targetLanguage: Language if (!postSlug) return false try { - return Boolean(localizedPostFiles[targetLanguage][`/content/posts/${targetLanguage}/${decodeURIComponent(postSlug)}.md`]) + return getPostAvailableLanguages(decodeURIComponent(postSlug)).includes(targetLanguage) } catch { return false } diff --git a/src/components/NavigationStatus.tsx b/src/components/NavigationStatus.tsx new file mode 100644 index 0000000..5b87eb8 --- /dev/null +++ b/src/components/NavigationStatus.tsx @@ -0,0 +1,39 @@ +import { useEffect, useState } from "react" +import { useNavigation } from "react-router-dom" +import { useT } from "@/i18n" + +function PendingNavigation({ href }: { href: string }) { + const t = useT() + const [visible, setVisible] = useState(false) + const [slow, setSlow] = useState(false) + + useEffect(() => { + const visibleTimer = setTimeout(() => setVisible(true), 150) + const slowTimer = setTimeout(() => setSlow(true), 5000) + return () => { + clearTimeout(visibleTimer) + clearTimeout(slowTimer) + } + }, []) + + if (!visible) return null + return ( + <> +
+ {t.common.navigating} + {slow && ( +
+ {t.common.navigationSlow} + {t.common.reloadPage} +
+ )} + + ) +} + +export function NavigationStatus() { + const navigation = useNavigation() + const target = navigation.location + if (!target) return null + return +} diff --git a/src/components/RoutePreloader.tsx b/src/components/RoutePreloader.tsx new file mode 100644 index 0000000..5004a14 --- /dev/null +++ b/src/components/RoutePreloader.tsx @@ -0,0 +1,32 @@ +import { useEffect } from "react" +import { prefetchRoute } from "@/lib/route-modules" + +export function RoutePreloader() { + useEffect(() => { + const connection = (navigator as Navigator & { connection?: { saveData?: boolean; effectiveType?: string } }).connection + if (connection?.saveData || /(^|-)2g$/.test(connection?.effectiveType ?? "")) return + + const paths = ["/posts", "/tags", "/series", "/about", "/analytics"] + let active = true + let idleId: number | undefined + let timer: ReturnType | undefined + + function schedule() { + if (!active || paths.length === 0) return + const run = () => { + if (!active) return + const path = paths.shift()! + void prefetchRoute(path).then(schedule) + } + if ("requestIdleCallback" in window) idleId = window.requestIdleCallback(run, { timeout: 2000 }) + else timer = setTimeout(run, 1000) + } + schedule() + return () => { + active = false + if (idleId !== undefined) window.cancelIdleCallback(idleId) + if (timer !== undefined) clearTimeout(timer) + } + }, []) + return null +} diff --git a/src/components/SearchCommand.tsx b/src/components/SearchCommand.tsx index 5932f05..4ed7aa9 100644 --- a/src/components/SearchCommand.tsx +++ b/src/components/SearchCommand.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react" import { useNavigate } from "react-router-dom" import { SearchIcon } from "lucide-react" +import { usePostSearchIndex } from "@/hooks/usePostData" import { searchPosts } from "@/lib/posts" import { analytics } from "@/lib/analytics" import { Button } from "@/components/ui/button" @@ -21,6 +22,7 @@ export function SearchCommand() { const navigate = useNavigate() const { language, t } = useLanguage() + const searchIndex = usePostSearchIndex(open && Boolean(query.trim()), language) const results = searchPosts(query, language) useEffect(() => { @@ -65,7 +67,7 @@ export function SearchCommand() { onValueChange={setQuery} /> - {t.components.noResults} + {searchIndex.loading ?

{t.common.searchLoading}

: searchIndex.error ?

{t.common.searchLoadError}

: {t.components.noResults}} {results.map((post) => ( { - document.startViewTransition(() => navigate(to)) + navigate(to) }, 300) } @@ -67,7 +68,6 @@ export function AppSidebar() { Devy @@ -86,9 +86,19 @@ export function AppSidebar() { isActive={isActive(item.basePath)} tooltip={item.label} > - handleMobileNav(e, item.to)}> - - {item.label} + { void prefetchRoute(item.to) }} + onFocus={() => { void prefetchRoute(item.to) }} + onTouchStart={() => { void prefetchRoute(item.to) }} + onClick={(e) => handleMobileNav(e, item.to)} + > + {({ isPending }) => ( + <> + + {item.label} + + )} @@ -96,7 +106,7 @@ export function AppSidebar() { {isAdmin && ( - handleMobileNav(e, "/admin")}> + handleMobileNav(e, "/admin")}> 관리자 @@ -132,7 +142,6 @@ export function AppSidebar() { {" | "} handleMobileNav(e, localizePath("/privacy", language))} className="hover:text-sidebar-foreground/80 transition-colors" > diff --git a/src/entry-server.tsx b/src/entry-server.tsx index 87a1085..ce548cd 100644 --- a/src/entry-server.tsx +++ b/src/entry-server.tsx @@ -10,6 +10,7 @@ import { getPostModifiedDate } from "./lib/post-dates" import { getRouteLanguage, localizePath, postPath } from "./lib/i18n-routing" import type { Language } from "./i18n" import type { PostMeta } from "./types/post" +export { preparePostContentForPrerender, getPostHydrationData } from "./lib/posts" export interface PrerenderRoute { path: string diff --git a/src/hooks/useDeferredModule.ts b/src/hooks/useDeferredModule.ts new file mode 100644 index 0000000..5572c9c --- /dev/null +++ b/src/hooks/useDeferredModule.ts @@ -0,0 +1,29 @@ +import { useEffect, useState } from "react" +import { ResourceLoadTimeoutError } from "@/lib/async-loader" + +export function useDeferredModule(load: () => Promise) { + const [value, setValue] = useState(null) + const [error, setError] = useState<{ cause: unknown } | null>(null) + const [attempt, setAttempt] = useState(0) + + useEffect(() => { + let active = true + void load().then( + (module) => { if (active) setValue(() => module) }, + (cause: unknown) => { if (active) setError({ cause }) }, + ) + return () => { active = false } + }, [load, attempt]) + + function retry() { + // A failed module fetch stays cached for this document in Chromium. + if (error && !(error.cause instanceof ResourceLoadTimeoutError)) { + window.location.reload() + return + } + setError(null) + setAttempt((current) => current + 1) + } + + return { value, error: error !== null, retry } +} diff --git a/src/hooks/usePostData.ts b/src/hooks/usePostData.ts new file mode 100644 index 0000000..4e7e052 --- /dev/null +++ b/src/hooks/usePostData.ts @@ -0,0 +1,20 @@ +import { useEffect, useSyncExternalStore } from "react" +import type { Language } from "@/i18n" +import { getPostDataVersion, getServerPostDataVersion, subscribePostData, loadPostContent, hasPostContent, hasPostContentError, getSearchStatus, getPostSearchText, loadPostSearch } from "@/lib/posts" + +export function usePostContent(slug: string | undefined, language: Language) { + useSyncExternalStore(subscribePostData, getPostDataVersion, getServerPostDataVersion) + useEffect(() => { + if (slug) void loadPostContent(slug, language).catch(() => undefined) + }, [slug, language]) + return { loaded: !!slug && hasPostContent(slug, language), error: !!slug && hasPostContentError(slug, language) } +} + +export function usePostSearchIndex(enabled: boolean, language: Language) { + useSyncExternalStore(subscribePostData, getPostDataVersion, getServerPostDataVersion) + useEffect(() => { + if (enabled) void loadPostSearch(language) + }, [enabled, language]) + const status = getSearchStatus(language) + return { texts: getPostSearchText(language), loading: enabled && (status === "idle" || status === "loading"), error: enabled && status === "error" } +} diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index 0dc3b0c..c311e1c 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -13,6 +13,16 @@ export interface Translations { recentPosts: string goHome: string viewAll: string + retry: string + chartLoading: string + chartLoadError: string + navigating: string + navigationSlow: string + reloadPage: string + postLoading: string + postLoadError: string + searchLoading: string + searchLoadError: string } home: { title: string @@ -220,6 +230,16 @@ export interface Translations { export const ko: Translations = { common: { + postLoading: "본문을 불러오는 중…", + postLoadError: "본문을 불러오지 못했습니다.", + searchLoading: "본문까지 검색하는 중…", + searchLoadError: "본문 검색을 불러오지 못했습니다. 현재 결과는 제목·설명·태그 기준입니다.", + retry: "다시 시도", + chartLoading: "차트를 불러오는 중…", + chartLoadError: "차트를 불러오지 못했습니다.", + navigating: "페이지를 불러오는 중…", + navigationSlow: "연결이 지연되고 있습니다.", + reloadPage: "페이지 새로 열기", home: "홈", posts: "글 목록", series: "시리즈", @@ -438,6 +458,16 @@ export const ko: Translations = { export const en: Translations = { common: { + postLoading: "Loading article…", + postLoadError: "Could not load the article.", + searchLoading: "Searching article content…", + searchLoadError: "Could not load full-text search. Current results match titles, descriptions, and tags.", + retry: "Try again", + chartLoading: "Loading chart…", + chartLoadError: "Could not load the chart.", + navigating: "Loading page…", + navigationSlow: "The connection is taking longer than expected.", + reloadPage: "Reload page", home: "Home", posts: "Posts", series: "Series", diff --git a/src/index.css b/src/index.css index ec00592..0df6170 100644 --- a/src/index.css +++ b/src/index.css @@ -261,6 +261,11 @@ html.theme-transition *::after { ::view-transition-new(root) { animation: fade-in 100ms ease-in; } +@media (prefers-reduced-motion: reduce) { + ::view-transition-new(root) { + animation: none; + } +} @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } diff --git a/src/layouts/RootLayout.tsx b/src/layouts/RootLayout.tsx index 54cd551..1918dab 100644 --- a/src/layouts/RootLayout.tsx +++ b/src/layouts/RootLayout.tsx @@ -7,6 +7,8 @@ import { SearchCommand } from "@/components/SearchCommand" import { ScrollToTopButton } from "@/components/ScrollToTopButton" import { Confetti } from "@/components/Confetti" import { LanguageSuggestionBanner } from "@/components/LanguageSuggestionBanner" +import { NavigationStatus } from "@/components/NavigationStatus" +import { RoutePreloader } from "@/components/RoutePreloader" import { trackPageView } from "@/lib/analytics" import { getRouteLanguage } from "@/lib/i18n-routing" import { useLanguage } from "@/i18n" @@ -44,6 +46,8 @@ function RouteLanguageSync() { export function RootLayout() { return ( + + diff --git a/src/lib/async-loader.ts b/src/lib/async-loader.ts new file mode 100644 index 0000000..84d1e26 --- /dev/null +++ b/src/lib/async-loader.ts @@ -0,0 +1,28 @@ +export class ResourceLoadTimeoutError extends Error { + constructor() { + super("Resource loading timed out") + this.name = "ResourceLoadTimeoutError" + } +} + +export function createRetryableLoader(load: () => Promise, timeoutMs = 30_000) { + let pending: Promise | undefined + + return function preload(): Promise { + if (pending) return pending + + let timer: ReturnType + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new ResourceLoadTimeoutError()), timeoutMs) + }) + + pending = Promise.race([Promise.resolve().then(load), timeout]) + .finally(() => clearTimeout(timer)) + .catch((error: unknown) => { + pending = undefined + throw error + }) + + return pending + } +} diff --git a/src/lib/post-frontmatter.ts b/src/lib/post-frontmatter.ts new file mode 100644 index 0000000..ad4b42d --- /dev/null +++ b/src/lib/post-frontmatter.ts @@ -0,0 +1,41 @@ +export function parseFrontmatter(raw: string): { + data: Record + content: string +} { + const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/) + if (!match) return { data: {}, content: raw } + + const frontmatter = match[1]! + const content = match[2]! + const data: Record = {} + + for (const line of frontmatter.split("\n")) { + const colonIdx = line.indexOf(":") + if (colonIdx === -1) continue + + const key = line.slice(0, colonIdx).trim() + let value: unknown = line.slice(colonIdx + 1).trim() + + // Remove surrounding quotes + if ( + typeof value === "string" && + value.startsWith('"') && + value.endsWith('"') + ) { + value = value.slice(1, -1) + } + + // Parse YAML-style arrays: ["a", "b"] + if (typeof value === "string" && value.startsWith("[")) { + try { + value = JSON.parse(value.replace(/'/g, '"')) + } catch { + // keep as string + } + } + + data[key] = value + } + + return { data, content } +} diff --git a/src/lib/posts.ts b/src/lib/posts.ts index 222593b..a1a907c 100644 --- a/src/lib/posts.ts +++ b/src/lib/posts.ts @@ -1,56 +1,106 @@ import type { Post, PostMeta } from "@/types/post" import type { Language } from "@/i18n" import { assertValidPostDates } from "@/lib/post-dates" +import { parseFrontmatter } from "@/lib/post-frontmatter" +import { getReadingMinutes } from "@/lib/reading-time" +import { createRetryableLoader } from "@/lib/async-loader" const postFiles = import.meta.glob("/content/posts/*/*.md", { - query: "?raw", + query: "?post-meta", import: "default", eager: true, }) as Record -const supportedLanguages = ["ko", "en"] as const satisfies readonly Language[] +const postBodies = import.meta.glob("/content/posts/*/*.md", { + query: "?post-body", + import: "default", +}) as Record Promise> + +const contentCache = new Map() +const contentLoaders = new Map Promise>() +const contentErrors = new Set() +const listeners = new Set<() => void>() +let version = 0 +function notify() { version++; listeners.forEach((listener) => listener()) } +export function subscribePostData(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener) } } +export function getPostDataVersion() { return version } +export function getServerPostDataVersion() { return 0 } +function postKey(slug: string, language: Language) { return `/content/posts/${language}/${slug}.md` } +export function hasPostContent(slug: string, language: Language) { return contentCache.has(postKey(slug, language)) } +export function hasPostContentError(slug: string, language: Language) { return contentErrors.has(postKey(slug, language)) } + +export function getPostHydrationData(pathname: string) { + const match = pathname.match(/^\/(en\/)?posts\/([^/]+)\/?$/) + if (!match) return undefined + const path = postKey(decodeURIComponent(match[2]!), match[1] ? "en" : "ko") + const raw = contentCache.get(path) + return raw ? { path, raw } : undefined +} -function parseFrontmatter(raw: string): { - data: Record - content: string -} { - const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/) - if (!match) return { data: {}, content: raw } - - const frontmatter = match[1]! - const content = match[2]! - const data: Record = {} - - for (const line of frontmatter.split("\n")) { - const colonIdx = line.indexOf(":") - if (colonIdx === -1) continue - - const key = line.slice(0, colonIdx).trim() - let value: unknown = line.slice(colonIdx + 1).trim() - - // Remove surrounding quotes - if ( - typeof value === "string" && - value.startsWith('"') && - value.endsWith('"') - ) { - value = value.slice(1, -1) - } +export function seedPostHydrationData(data: unknown) { + if (!data || typeof data !== "object" || !("path" in data) || !("raw" in data)) return + if (typeof data.path !== "string" || typeof data.raw !== "string" || !postFiles[data.path]) return + contentCache.set(data.path, data.raw) +} - // Parse YAML-style arrays: ["a", "b"] - if (typeof value === "string" && value.startsWith("[")) { - try { - value = JSON.parse(value.replace(/'/g, '"')) - } catch { - // keep as string - } - } +export function loadPostContent(slug: string, language: Language): Promise { + const key = postKey(slug, language) + if (!postFiles[key] || contentCache.has(key)) return Promise.resolve() + let load = contentLoaders.get(key) + if (!load) { + const read = postBodies[key] + if (!read) return Promise.resolve() + load = createRetryableLoader(async () => { + const raw = await read() + contentCache.set(key, raw) + contentErrors.delete(key) + notify() + }) + contentLoaders.set(key, load) + } + return load().catch((error: unknown) => { contentErrors.add(key); notify(); throw error }) +} - data[key] = value +export async function preloadPostForPath(pathname: string) { + const match = pathname.match(/^\/(en\/)?posts\/([^/]+)\/?$/) + if (!match) return + try { + await loadPostContent(decodeURIComponent(match[2]!), match[1] ? "en" : "ko") + } catch { + // Hydrate the metadata shell and let the article retry UI handle a failed body request. } +} + +export async function preparePostContentForPrerender() { + await Promise.all(Object.keys(postFiles).map((path) => { + const parsed = parsePostPath(path) + return parsed ? loadPostContent(parsed.slug, parsed.language) : Promise.resolve() + })) +} - return { data, content } +type SearchStatus = "idle" | "loading" | "ready" | "error" +const searchText: Partial>> = {} +const searchStatus: Record = { ko: "idle", en: "idle" } +const searchLoaders = { + ko: createRetryableLoader(() => import("virtual:post-search/ko")), + en: createRetryableLoader(() => import("virtual:post-search/en")), } +export function getSearchStatus(language: Language) { return searchStatus[language] } +export function getPostSearchText(language: Language) { return searchText[language] } +export async function loadPostSearch(language: Language) { + if (searchStatus[language] === "ready") return + searchStatus[language] = "loading" + notify() + try { + searchText[language] = (await searchLoaders[language]()).default + searchStatus[language] = "ready" + } catch { + searchStatus[language] = "error" + } + notify() +} + +const supportedLanguages = ["ko", "en"] as const satisfies readonly Language[] function parsePostPath(filePath: string): { language: Language; slug: string } | undefined { const match = filePath.match(/^\/content\/posts\/(ko|en)\/(.+)\.md$/) @@ -90,6 +140,7 @@ function parsePost(filePath: string, raw: string): Post { draft: data.draft === true || data.draft === "true", publishDate: (data.publishDate as string) || undefined, content, + readingMinutes: Number(data.readingMinutes) || getReadingMinutes(content), } assertValidPostDates(post) @@ -116,12 +167,13 @@ function toPostMeta(post: Post): PostMeta { seriesOrder: post.seriesOrder, draft: post.draft, publishDate: post.publishDate, + readingMinutes: post.readingMinutes, } } function getAllParsedPosts(language: Language): Post[] { return Object.entries(postFiles) - .filter(([path]) => parsePostPath(path)?.language === language) + .filter(([path, raw]) => Boolean(raw) && parsePostPath(path)?.language === language) .map(([path, raw]) => parsePost(path, raw)) .filter((post) => !import.meta.env.PROD || !isHidden(post)) } @@ -146,7 +198,7 @@ export function getPostsByTag(tag: string, language: Language = "ko"): PostMeta[ export function getPostBySlug(slug: string, language: Language = "ko"): Post | undefined { const path = `/content/posts/${language}/${slug}.md` - const raw = postFiles[path] + const raw = contentCache.get(path) ?? postFiles[path] if (!raw) return undefined const post = parsePost(path, raw) if (import.meta.env.PROD && isHidden(post)) return undefined @@ -157,7 +209,7 @@ export function getPostAvailableLanguages(slug: string): Language[] { return getAvailableLanguages(slug) } -export function searchPosts(query: string, language: Language = "ko"): PostMeta[] { +export function searchPosts(query: string, language: Language = "ko", texts = searchText[language]): PostMeta[] { if (!query.trim()) return getAllPosts(language) const q = query.toLowerCase() return getAllParsedPosts(language) @@ -166,7 +218,7 @@ export function searchPosts(query: string, language: Language = "ko"): PostMeta[ post.title.toLowerCase().includes(q) || post.description.toLowerCase().includes(q) || post.tags.some((t) => t.toLowerCase().includes(q)) || - post.content.toLowerCase().includes(q) + (texts?.[post.slug] ?? "").includes(q) ) }) .map(toPostMeta) @@ -189,7 +241,7 @@ export function advancedSearch(options: { post.title.toLowerCase().includes(q) || post.description.toLowerCase().includes(q) || post.tags.some((t) => t.toLowerCase().includes(q)) || - post.content.toLowerCase().includes(q) + (searchText[language]?.[post.slug] ?? "").includes(q) )) return false if (dateFrom && post.date < dateFrom) return false if (dateTo && post.date > dateTo) return false diff --git a/src/lib/route-modules.ts b/src/lib/route-modules.ts new file mode 100644 index 0000000..33968bc --- /dev/null +++ b/src/lib/route-modules.ts @@ -0,0 +1,75 @@ +import type { ComponentType } from "react" +import { stripLanguagePrefix } from "./i18n-routing" +import { createRetryableLoader } from "./async-loader" +import { preloadPostForPath } from "./posts" + +export type RouteComponentKey = + | "posts" + | "post" + | "tags" + | "series" + | "search" + | "analytics" + | "about" + | "project" + | "privacy" + | "admin" + | "adminCallback" + +export type ResolvedRouteComponents = Partial> + +const factories: Record Promise<{ Component: ComponentType }>> = { + posts: () => import("../pages/PostsPage").then((module) => ({ Component: module.PostsPage })), + post: () => import("../pages/PostPage").then((module) => ({ Component: module.PostPage })), + tags: () => import("../pages/TagsPage").then((module) => ({ Component: module.TagsPage })), + series: () => import("../pages/SeriesPage").then((module) => ({ Component: module.SeriesPage })), + search: () => import("../pages/SearchPage").then((module) => ({ Component: module.SearchPage })), + analytics: () => import("../pages/AnalyticsPage").then((module) => ({ Component: module.AnalyticsPage })), + about: () => import("../pages/AboutPage").then((module) => ({ Component: module.AboutPage })), + project: () => import("../pages/ProjectDetailPage").then((module) => ({ Component: module.ProjectDetailPage })), + privacy: () => import("../pages/PrivacyPage").then((module) => ({ Component: module.PrivacyPage })), + admin: () => import("../pages/admin/AdminPage").then((module) => ({ Component: module.AdminPage })), + adminCallback: () => import("../pages/admin/AdminCallbackPage").then((module) => ({ Component: module.AdminCallbackPage })), +} + + +const loaders = new Map Promise<{ Component: ComponentType }>>() + +export function loadRouteModule(key: RouteComponentKey) { + let load = loaders.get(key) + if (!load) { + load = createRetryableLoader(factories[key]) + loaders.set(key, load) + } + return load() +} + +export function getRouteComponentKey(pathname: string): RouteComponentKey | null { + const path = stripLanguagePrefix(pathname).replace(/\/+$/, "") || "/" + if (path === "/posts") return "posts" + if (path.startsWith("/posts/")) return "post" + if (path === "/tags") return "tags" + if (path === "/series") return "series" + if (path === "/search") return "search" + if (path === "/analytics") return "analytics" + if (path === "/about") return "about" + if (path.startsWith("/about/projects/")) return "project" + if (path === "/privacy") return "privacy" + if (path === "/admin") return "admin" + if (path === "/admin/callback") return "adminCallback" + return null +} + +export async function preloadRouteComponents(pathname: string): Promise { + const key = getRouteComponentKey(pathname) + if (!key) return {} + + const [{ Component }] = await Promise.all([loadRouteModule(key), preloadPostForPath(pathname)]) + return { [key]: Component } +} + + +export function prefetchRoute(pathname: string) { + const key = getRouteComponentKey(pathname) + return key ? loadRouteModule(key).then(() => undefined, () => undefined) : Promise.resolve() +} diff --git a/src/main.tsx b/src/main.tsx index d999f88..b363f21 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,8 +3,15 @@ import { AppProviders } from "./app-shell" import { App } from "./App" import { createRoutes, preloadRouteComponents } from "./routes" import "./index.css" +import { seedPostHydrationData } from "./lib/posts" +import { AppLoadError } from "./pages/ErrorPage" +import { getRouteLanguage } from "./lib/i18n-routing" async function start() { + const postData = document.getElementById("post-hydration-data")?.textContent + if (postData) { + try { seedPostHydrationData(JSON.parse(postData)) } catch { /* Fall back to the article loader. */ } + } const resolvedComponents = await preloadRouteComponents(window.location.pathname) const routes = createRoutes(resolvedComponents) const app = ( @@ -22,4 +29,10 @@ async function start() { } } -void start() +void start().catch(() => { + createRoot(document.getElementById("root")!).render( + + + + ) +}) diff --git a/src/pages/AnalyticsPage.tsx b/src/pages/AnalyticsPage.tsx index f0cf3b6..03db118 100644 --- a/src/pages/AnalyticsPage.tsx +++ b/src/pages/AnalyticsPage.tsx @@ -4,15 +4,10 @@ import { Activity, AlertCircle, CalendarDays, Eye, FileText, Gauge, Library, Pen import { Alert, AlertDescription } from "@/components/ui/alert" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" -import { Bar, BarChart, CartesianGrid, Label, Pie, PieChart, XAxis, YAxis } from "recharts" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { - ChartContainer, - ChartTooltip, - ChartTooltipContent, - type ChartConfig, -} from "@/components/ui/chart" -import { DailyVisitsChart } from "@/components/DailyVisitsChart" +import { ChartPlaceholder } from "@/components/ChartPlaceholder" +import { useDeferredModule } from "@/hooks/useDeferredModule" +import { createRetryableLoader } from "@/lib/async-loader" import { PageContainer } from "@/components/PageContainer" import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" import { usePageViews } from "@/hooks/usePageViews" @@ -22,6 +17,8 @@ import { getAllPosts, getAllSeries, getAllTags } from "@/lib/posts" import { useLanguage } from "@/i18n" import { localizePath, postPath } from "@/lib/i18n-routing" +const loadAnalyticsCharts = createRetryableLoader(() => import("@/components/AnalyticsCharts")) + const TAG_COLORS = [ "oklch(0.65 0.2 250)", // blue "oklch(0.65 0.2 150)", // green @@ -66,6 +63,7 @@ function formatPercent(value: number | null) { export function AnalyticsPage() { const { language, t } = useLanguage() + const { value: charts, error: chartError, retry: retryCharts } = useDeferredModule(loadAnalyticsCharts) const [rangeDays, setRangeDays] = useState(14) useMetaTags({ title: t.common.analytics, description: t.analytics.description, url: localizePath("/analytics", language) }) @@ -168,21 +166,6 @@ export function AnalyticsPage() { return result }, [posts, t]) - const tagChartConfig = useMemo(() => { - const config: ChartConfig = { count: { label: t.analytics.postsCount } } - for (const item of tagDistribution) { - config[item.tag] = { label: item.tag, color: item.fill } - } - return config - }, [tagDistribution, t]) - - const monthlyChartConfig = useMemo(() => ({ - count: { - label: t.analytics.postsCount, - color: "var(--primary)", - }, - }), [t]) - const monthlyPosts = useMemo(() => { const map = new Map() for (const post of posts) { @@ -293,12 +276,11 @@ export function AnalyticsPage() { {/* Daily Visits Chart */}
- + {charts ? ( + + ) : ( + + )}
{/* Traffic Momentum */} @@ -479,37 +461,7 @@ export function AnalyticsPage() { {tagDistribution.length === 0 ? (

{t.analytics.noData}

) : ( - - - } /> - - - - + charts ? : )} @@ -527,39 +479,7 @@ export function AnalyticsPage() { {monthlyPosts.length === 0 ? (

{t.analytics.noData}

) : ( - - - - { - const [y, m] = v.split("-") - return `${y.slice(2)}/${m}` - }} - tickLine={false} - axisLine={false} - tickMargin={8} - minTickGap={30} - /> - - } - labelFormatter={(v) => v} - /> - - - + charts ? : )} diff --git a/src/pages/ErrorPage.tsx b/src/pages/ErrorPage.tsx index db88bb8..4e4b0bd 100644 --- a/src/pages/ErrorPage.tsx +++ b/src/pages/ErrorPage.tsx @@ -1,4 +1,4 @@ -import { Link, useRouteError } from "react-router-dom" +import { useRouteError } from "react-router-dom" import { PageContainer } from "@/components/PageContainer" import { Button } from "@/components/ui/button" import { useLanguage } from "@/i18n" @@ -6,12 +6,17 @@ import { localizePath } from "@/lib/i18n-routing" export function ErrorPage() { const error = useRouteError() - const { language, t } = useLanguage() if (import.meta.env.DEV) { console.error(error) } + return +} + +export function AppLoadError() { + const { language, t } = useLanguage() + return (

@@ -23,9 +28,12 @@ export function ErrorPage() {

{t.notFound.errorMessage}

- +
+ + +
) } diff --git a/src/pages/PostPage.tsx b/src/pages/PostPage.tsx index d24830a..c634f81 100644 --- a/src/pages/PostPage.tsx +++ b/src/pages/PostPage.tsx @@ -29,6 +29,7 @@ function rehypeCodeBrFix() { } } import { getPostBySlug, getAdjacentPosts } from "@/lib/posts" +import { usePostContent } from "@/hooks/usePostData" import { getReadingMinutes } from "@/lib/reading-time" import { Alert, AlertDescription } from "@/components/ui/alert" import { Badge } from "@/components/ui/badge" @@ -98,6 +99,7 @@ function MarkdownCodeBlock(props: ComponentPropsWithoutRef<"pre">) { export function PostPage() { const { slug } = useParams<{ slug: string }>() const { language, setLanguage, t } = useLanguage() + const body = usePostContent(slug, language) const post = slug ? getPostBySlug(slug, language) : undefined const fallbackPost = !post && slug && language === "en" ? getPostBySlug(slug, "ko") : undefined const { prev, next } = slug && post ? getAdjacentPosts(slug, language) : { prev: null, next: null } @@ -208,7 +210,7 @@ export function PostPage() { )} · - {t.post.readingTime(getReadingMinutes(post.content))} + {t.post.readingTime(post.readingMinutes ?? getReadingMinutes(post.content))} · @@ -257,7 +259,12 @@ export function PostPage() { )}
- +

{body.error ? t.common.postLoadError : t.common.postLoading}

+ {body.error && } +
+ ) : {post.content} - + }
{(prev || next) && ( @@ -318,7 +325,7 @@ export function PostPage() {
- + {body.loaded && }
) diff --git a/src/pages/PostsPage.tsx b/src/pages/PostsPage.tsx index 643aa34..7c0e215 100644 --- a/src/pages/PostsPage.tsx +++ b/src/pages/PostsPage.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from "react" import { useSearchParams } from "react-router-dom" +import { usePostSearchIndex } from "@/hooks/usePostData" import { useMetaTags } from "@/hooks/useMetaTags" import { LayoutListIcon, LayoutGridIcon, SearchIcon, XIcon } from "lucide-react" import { getAllPosts, searchPosts } from "@/lib/posts" @@ -67,6 +68,8 @@ export function PostsPage() { const sortMode = toSortMode(searchParams.get("sort")) const searchScope = toSearchScope(searchParams.get("scope")) + const searchIndex = usePostSearchIndex(Boolean(query.trim()) && searchScope === "all", language) + function updateParam(key: string, value: string, defaultValue = "") { const next = new URLSearchParams(searchParams) if (!value || value === defaultValue) { @@ -106,7 +109,7 @@ export function PostsPage() { const trimmedQuery = query.trim() let result = trimmedQuery ? searchScope === "all" - ? searchPosts(trimmedQuery, language) + ? searchPosts(trimmedQuery, language, searchIndex.texts) : allPosts.filter((post) => ( searchScope === "summary" ? postMatchesSummary(post, trimmedQuery) @@ -130,7 +133,7 @@ export function PostsPage() { } return a.date > b.date ? -1 : 1 }) - }, [allPosts, getPostViews, language, query, searchScope, selectedTag, selectedYear, sortMode]) + }, [allPosts, getPostViews, language, query, searchScope, searchIndex.texts, selectedTag, selectedYear, sortMode]) const groupedByYear = useMemo(() => { const groups = new Map() @@ -261,7 +264,9 @@ export function PostsPage() {

- {filteredPosts.length === 0 ? ( + {searchIndex.loading &&

{t.common.searchLoading}

} + {searchIndex.error &&

{t.common.searchLoadError}

} + {filteredPosts.length === 0 && (searchIndex.loading || searchIndex.error) ? null : filteredPosts.length === 0 ? ( ) : ( [...groupedByYear.entries()].map(([year, yearPosts]) => ( diff --git a/src/routes.tsx b/src/routes.tsx index b2edd51..c084b9c 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -1,67 +1,14 @@ -import type { ComponentType } from "react" import type { RouteObject } from "react-router-dom" import { RootLayout } from "./layouts/RootLayout" import { HomePage } from "./pages/HomePage" import { ErrorPage } from "./pages/ErrorPage" import { NotFoundPage } from "./pages/NotFoundPage" -import { stripLanguagePrefix } from "./lib/i18n-routing" - -type RouteComponentKey = - | "posts" - | "post" - | "tags" - | "series" - | "search" - | "analytics" - | "about" - | "project" - | "privacy" - | "admin" - | "adminCallback" - -type ResolvedRouteComponents = Partial> - -const routeComponentLoaders: Record Promise<{ Component: ComponentType }>> = { - posts: () => import("./pages/PostsPage").then((module) => ({ Component: module.PostsPage })), - post: () => import("./pages/PostPage").then((module) => ({ Component: module.PostPage })), - tags: () => import("./pages/TagsPage").then((module) => ({ Component: module.TagsPage })), - series: () => import("./pages/SeriesPage").then((module) => ({ Component: module.SeriesPage })), - search: () => import("./pages/SearchPage").then((module) => ({ Component: module.SearchPage })), - analytics: () => import("./pages/AnalyticsPage").then((module) => ({ Component: module.AnalyticsPage })), - about: () => import("./pages/AboutPage").then((module) => ({ Component: module.AboutPage })), - project: () => import("./pages/ProjectDetailPage").then((module) => ({ Component: module.ProjectDetailPage })), - privacy: () => import("./pages/PrivacyPage").then((module) => ({ Component: module.PrivacyPage })), - admin: () => import("./pages/admin/AdminPage").then((module) => ({ Component: module.AdminPage })), - adminCallback: () => import("./pages/admin/AdminCallbackPage").then((module) => ({ Component: module.AdminCallbackPage })), -} +import { loadRouteModule, type RouteComponentKey, type ResolvedRouteComponents } from "./lib/route-modules" +export { preloadRouteComponents } from "./lib/route-modules" function routeComponent(key: RouteComponentKey, resolvedComponents: ResolvedRouteComponents) { const Component = resolvedComponents[key] - return Component ? { Component } : { lazy: routeComponentLoaders[key] } -} - -function getRouteComponentKey(pathname: string): RouteComponentKey | null { - const path = stripLanguagePrefix(pathname).replace(/\/+$/, "") || "/" - if (path === "/posts") return "posts" - if (path.startsWith("/posts/")) return "post" - if (path === "/tags") return "tags" - if (path === "/series") return "series" - if (path === "/search") return "search" - if (path === "/analytics") return "analytics" - if (path === "/about") return "about" - if (path.startsWith("/about/projects/")) return "project" - if (path === "/privacy") return "privacy" - if (path === "/admin") return "admin" - if (path === "/admin/callback") return "adminCallback" - return null -} - -export async function preloadRouteComponents(pathname: string): Promise { - const key = getRouteComponentKey(pathname) - if (!key) return {} - - const { Component } = await routeComponentLoaders[key]() - return { [key]: Component } + return Component ? { Component } : { lazy: () => loadRouteModule(key) } } export function createRoutes(resolvedComponents: ResolvedRouteComponents = {}): RouteObject[] { diff --git a/src/types/post.ts b/src/types/post.ts index b368c92..dfc86db 100644 --- a/src/types/post.ts +++ b/src/types/post.ts @@ -8,6 +8,7 @@ export interface PostMeta { date: string updated: string description: string + readingMinutes?: number tags: string[] series?: string seriesOrder?: number diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index bea6af7..fd7448d 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,5 +1,14 @@ /// +declare module "virtual:post-search/ko" { + const texts: Record + export default texts +} +declare module "virtual:post-search/en" { + const texts: Record + export default texts +} + interface ImportMetaEnv { /** Google Apps Script API URL (조회수 조회) */ readonly VITE_GA_API_URL?: string diff --git a/vite.config.ts b/vite.config.ts index ed969a6..aa01522 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,6 +2,7 @@ import path from "path" import fs from "fs" import { defineConfig, type Plugin } from "vite" import react from "@vitejs/plugin-react" +import { postAssetsPlugin } from "./scripts/post-assets" import { assertValidPostDates, getPostModifiedDate } from "./src/lib/post-dates" import { PROJECTS } from "./src/data/resume" @@ -203,6 +204,7 @@ ${urls.join("\n")} export default defineConfig(({ isSsrBuild }) => ({ plugins: [ + postAssetsPlugin(), react(), ...(!isSsrBuild ? [sitemapPlugin(), rssPlugin()] : []), ], @@ -212,6 +214,7 @@ export default defineConfig(({ isSsrBuild }) => ({ }, }, build: { + manifest: true, outDir: "dist", rollupOptions: { output: {