diff --git a/packages/lib/src/Router.ts b/packages/lib/src/Router.ts index eb3bd157..2f7696c1 100644 --- a/packages/lib/src/Router.ts +++ b/packages/lib/src/Router.ts @@ -1,4 +1,6 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { createObserver } from "./createObserver"; +import { isServer } from "./ssrUtils"; import type { AnyFunction, StringRecord } from "./types"; interface Route { @@ -11,39 +13,45 @@ interface Route { type QueryPayload = Record; export type RouterInstance = InstanceType>; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any export class Router any> { + [x: string]: any; readonly #routes: Map>; readonly #observer = createObserver(); readonly #baseUrl; #route: null | (Route & { params: StringRecord; path: string }); + readonly #isServer: boolean; constructor(baseUrl = "") { this.#routes = new Map(); this.#route = null; this.#baseUrl = baseUrl.replace(/\/$/, ""); - - window.addEventListener("popstate", () => { - this.#route = this.#findRoute(); - this.#observer.notify(); - }); - - document.addEventListener("click", (e) => { - const target = e.target as HTMLElement; - if (!target?.closest("[data-link]")) { - return; - } - e.preventDefault(); - const url = target.getAttribute("href") ?? target.closest("[data-link]")?.getAttribute("href"); - if (url) { - this.push(url); - } - }); + this.#isServer = isServer; + + if (!this.#isServer) { + window.addEventListener("popstate", () => { + this.#route = this.#findRoute(); + this.#observer.notify(); + }); + + document.addEventListener("click", (e) => { + const target = e.target as HTMLElement; + if (!target?.closest("[data-link]")) { + return; + } + e.preventDefault(); + const url = target.getAttribute("href") ?? target.closest("[data-link]")?.getAttribute("href"); + if (url) { + this.push(url); + } + }); + } } get query(): StringRecord { + if (this.#isServer) { + return {}; + } return Router.parseQuery(window.location.search); } @@ -67,7 +75,17 @@ export class Router any> { readonly subscribe = this.#observer.subscribe; addRoute(path: string, handler: Handler) { - // 경로 패턴을 정규식으로 변환 + // '*' 전용 처리 + if (path === "*" || path === "/*") { + this.#routes.set(path, { + regex: new RegExp(`^${this.#baseUrl}.*$`), + paramNames: [], + handler, + }); + return; + } + + // 일반적인 파라미터(:id) 처리 const paramNames: string[] = []; const regexPath = path .replace(/:\w+/g, (match) => { @@ -85,8 +103,22 @@ export class Router any> { }); } - #findRoute(url = window.location.pathname) { - const { pathname } = new URL(url, window.location.origin); + #findRoute(url?: string) { + if (this.#isServer) { + // SSR에서는 기본 라우트 반환 + const defaultRoute = this.#routes.get("/"); + if (defaultRoute) { + return { + ...defaultRoute, + params: {}, + path: "/", + }; + } + return null; + } + + const actualUrl = url || window.location.pathname; + const { pathname } = new URL(actualUrl, window.location.origin); for (const [routePath, route] of this.#routes) { const match = pathname.match(route.regex); if (match) { @@ -107,6 +139,10 @@ export class Router any> { } push(url: string) { + if (this.#isServer) { + return; + } + try { // baseUrl이 없으면 자동으로 붙여줌 const fullUrl = url.startsWith(this.#baseUrl) ? url : this.#baseUrl + (url.startsWith("/") ? url : "/" + url); @@ -130,8 +166,13 @@ export class Router any> { this.#observer.notify(); } - static parseQuery = (search = window.location.search) => { - const params = new URLSearchParams(search); + static parseQuery = (search?: string) => { + if (isServer) { + return {}; + } + + const actualSearch = search || window.location.search; + const params = new URLSearchParams(actualSearch); const query: StringRecord = {}; for (const [key, value] of params) { query[key] = value; @@ -150,6 +191,10 @@ export class Router any> { }; static getUrl = (newQuery: QueryPayload, baseUrl = "") => { + if (isServer) { + return "/"; + } + const currentQuery = Router.parseQuery(); const updatedQuery = { ...currentQuery, ...newQuery }; diff --git a/packages/lib/src/createStorage.ts b/packages/lib/src/createStorage.ts index fdf2986c..a2efa59c 100644 --- a/packages/lib/src/createStorage.ts +++ b/packages/lib/src/createStorage.ts @@ -1,6 +1,7 @@ import { createObserver } from "./createObserver.ts"; +import { safeLocalStorage } from "./ssrUtils.js"; -export const createStorage = (key: string, storage = window.localStorage) => { +export const createStorage = (key: string, storage = safeLocalStorage) => { let data: T | null = JSON.parse(storage.getItem(key) ?? "null"); const { subscribe, notify } = createObserver(); diff --git a/packages/lib/src/hooks/index.ts b/packages/lib/src/hooks/index.ts index 800a96a3..1cfd6d2a 100644 --- a/packages/lib/src/hooks/index.ts +++ b/packages/lib/src/hooks/index.ts @@ -8,3 +8,4 @@ export * from "./useShallowSelector"; export * from "./useShallowState"; export * from "./useStorage"; export * from "./useStore"; +export * from "./useSSRSafeExternalStore"; diff --git a/packages/lib/src/hooks/useRouter.ts b/packages/lib/src/hooks/useRouter.ts index 4a40cb5d..eff095f6 100644 --- a/packages/lib/src/hooks/useRouter.ts +++ b/packages/lib/src/hooks/useRouter.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import type { RouterInstance } from "../Router"; import type { AnyFunction } from "../types"; import { useSyncExternalStore } from "react"; @@ -5,7 +6,19 @@ import { useShallowSelector } from "./useShallowSelector"; const defaultSelector = (state: T) => state as unknown as S; -export const useRouter = , S>(router: T, selector = defaultSelector) => { +export const useRouter = , S = T>( + router: T, + selector: (r: T) => S = defaultSelector as unknown as (r: T) => S, +) => { const shallowSelector = useShallowSelector(selector); - return useSyncExternalStore(router.subscribe, () => shallowSelector(router)); + + // 라우터 인스턴스 자체를 읽는다 (getState 기대 X) + const getSnapshot = () => shallowSelector(router); + + // subscribe는 필수. 없으면 no-op + + const subscribe = typeof (router as any).subscribe === "function" ? (router as any).subscribe : () => () => {}; + + // ✅ SSR 호환: 3번째 인자(getServerSnapshot) 추가 + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); }; diff --git a/packages/lib/src/hooks/useSSRSafeExternalStore.ts b/packages/lib/src/hooks/useSSRSafeExternalStore.ts new file mode 100644 index 00000000..e659b45c --- /dev/null +++ b/packages/lib/src/hooks/useSSRSafeExternalStore.ts @@ -0,0 +1,10 @@ +import { useSyncExternalStore } from "react"; + +/** SSR에서도 안전하게 동작: 세 번째 인자가 비면 getSnapshot을 재사용 */ +export function useSSRSafeExternalStore( + subscribe: (listener: () => void) => () => void, + getSnapshot: () => T, + getServerSnapshot?: () => T, +) { + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot ?? getSnapshot); +} diff --git a/packages/lib/src/hooks/useStore.ts b/packages/lib/src/hooks/useStore.ts index 56fa8800..992f09cf 100644 --- a/packages/lib/src/hooks/useStore.ts +++ b/packages/lib/src/hooks/useStore.ts @@ -8,5 +8,6 @@ const defaultSelector = (state: T) => state as unknown as S; export const useStore = (store: Store, selector: (state: T) => S = defaultSelector) => { const shallowSelector = useShallowSelector(selector); - return useSyncExternalStore(store.subscribe, () => shallowSelector(store.getState())); + const getSnapshot = () => shallowSelector(store.getState()); + return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot); }; diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts index 74605597..24cf2ddb 100644 --- a/packages/lib/src/index.ts +++ b/packages/lib/src/index.ts @@ -5,3 +5,4 @@ export * from "./Router"; export { useStore, useStorage, useRouter, useAutoCallback } from "./hooks"; export * from "./equals"; export * from "./types"; +export * from "./"; diff --git a/packages/lib/src/ssrUtils.js b/packages/lib/src/ssrUtils.js new file mode 100644 index 00000000..b061e21e --- /dev/null +++ b/packages/lib/src/ssrUtils.js @@ -0,0 +1,66 @@ +/** + * SSR 환경 체크 및 브라우저 전용 함수 래퍼 + */ + +export const isServer = typeof window === "undefined"; +export const isBrowser = !isServer; + +/** + * 브라우저에서만 실행되는 함수 래퍼 + */ +export const clientOnly = (fn, fallback = () => {}) => { + return (...args) => { + if (isBrowser) { + return fn(...args); + } + return fallback(...args); + }; +}; + +/** + * 서버에서만 실행되는 함수 래퍼 + */ +export const serverOnly = (fn, fallback = () => {}) => { + return (...args) => { + if (isServer) { + return fn(...args); + } + return fallback(...args); + }; +}; + +/** + * 안전한 localStorage 래퍼 + */ +export const safeLocalStorage = { + getItem: clientOnly( + (key) => window.localStorage.getItem(key), + () => null, + ), + setItem: clientOnly( + (key, value) => window.localStorage.setItem(key, value), + () => {}, + ), + removeItem: clientOnly( + (key) => window.localStorage.removeItem(key), + () => {}, + ), +}; + +/** + * 안전한 DOM 접근 래퍼 + */ +export const safeDocument = { + getElementById: clientOnly( + (id) => document.getElementById(id), + () => null, + ), + querySelector: clientOnly( + (selector) => document.querySelector(selector), + () => null, + ), + addEventListener: clientOnly( + (event, handler) => document.addEventListener(event, handler), + () => {}, + ), +}; diff --git a/packages/react/index.html b/packages/react/index.html index c93c0168..577d85ee 100644 --- a/packages/react/index.html +++ b/packages/react/index.html @@ -1,26 +1,26 @@ - - - - - - - - - -
- - + + + + + + + + + +
+ + diff --git a/packages/react/server.js b/packages/react/server.js index 6b9430ac..f5d7c21d 100644 --- a/packages/react/server.js +++ b/packages/react/server.js @@ -1,32 +1,189 @@ import express from "express"; -import { renderToString } from "react-dom/server"; -import { createElement } from "react"; +import fs from "fs"; const prod = process.env.NODE_ENV === "production"; -const port = process.env.PORT || 5174; +const port = process.env.PORT || 5176; const base = process.env.BASE || (prod ? "/front_6th_chapter4-1/react/" : "/"); const app = express(); -app.get("*all", (req, res) => { - res.send( - ` - - - - - - React SSR - - -
${renderToString(createElement("div", null, "안녕하세요"))}
- - - `.trim(), - ); +// ✅ 모든 요청 로깅 미들웨어 (가장 먼저) +app.use((req, res, next) => { + console.log(`🌍 [${new Date().toISOString()}] ${req.method} ${req.originalUrl}`); + console.log(`🔍 User-Agent: ${req.headers["user-agent"]}`); + next(); }); -// Start http server +// ✅ 정적 파일 서빙 추가 (public 폴더만) +app.use(express.static("public")); + +let vite; +if (!prod) { + console.log("🔧 개발 모드: Vite 서버 설정 중..."); + + const { createServer } = await import("vite"); + vite = await createServer({ + server: { middlewareMode: true }, + appType: "custom", + base, + }); + + // ✅ Vite 미들웨어가 모든 개발 파일을 처리하도록 + app.use(vite.middlewares); +} else { + console.log("🚀 프로덕션 모드: 정적 파일 서빙 설정 중..."); + + const compression = (await import("compression")).default; + const sirv = (await import("sirv")).default; + app.use(compression()); + app.use(base, sirv("./dist/react", { extensions: [] })); +} + +// HTML 템플릿 읽기 +const templateHtml = prod ? fs.readFileSync("./dist/react/index.html", "utf-8") : ""; + +app.get("/favicon.ico", (_, res) => { + res.status(204).end(); +}); +app.get("/.well-known/appspecific/com.chrome.devtools.json", (_, res) => { + res.status(204).end(); +}); + +// ✅ SSR 라우트 핸들러 (모든 HTML 요청 처리) +app.get("*all", async (req, res) => { + // 정적 파일 요청은 제외 + if (req.path.includes(".") && !req.path.endsWith("/")) { + return res.status(404).send("Not Found"); + } + + try { + console.log("🎯 === SSR 요청 디버그 ==="); + console.log("🎯 URL:", req.originalUrl); + console.log("🎯 Path:", req.path); + console.log("🎯 Query:", req.query); + console.log("🎯 Method:", req.method); + console.log("🎯 //=== SSR 요청 디버그 ==="); + + const url = req.originalUrl.replace(base, ""); + const query = req.query; + + let template, render; + + try { + if (!prod) { + // 개발 환경 + console.log("📖 개발 모드: index.html 읽기..."); + template = fs.readFileSync("./index.html", "utf-8"); + template = await vite.transformIndexHtml(url, template); + + console.log("📦 개발 모드: main-server.tsx 로드..."); + try { + render = (await vite.ssrLoadModule("./src/main-server.tsx")).render; + } catch (ssrError) { + console.error("❌ SSR 모듈 로드 실패:", ssrError); + // SSR 실패 시 클라이언트 사이드 렌더링으로 폴백 + render = () => ({ + html: '
', + head: "쇼핑몰", + initialData: {}, + }); + } + } else { + // 프로덕션 환경 + console.log("📖 프로덕션 모드: 템플릿 사용..."); + template = templateHtml; + try { + render = (await import("./dist/react-ssr/main-server.js")).render; + } catch (ssrError) { + console.error("❌ 프로덕션 SSR 모듈 로드 실패:", ssrError); + render = () => ({ + html: '
', + head: "쇼핑몰", + initialData: {}, + }); + } + } + + console.log("✅ 템플릿 및 render 함수 로드 성공"); + + // render 함수 호출 + console.log("🔄 SSR 렌더링 시작..."); + console.log(url); + + const { html, head, initialData } = await render(url, query); + + console.log("✅ SSR 렌더링 완료"); + console.log("📄 HTML 길이:", html?.length || 0); + console.log("🏷️ Head:", head?.substring(0, 100) + "..."); + console.log("💾 Initial Data keys:", Object.keys(initialData || {})); + + console.log(initialData); + + // 초기 데이터 스크립트 생성 + const initialDataScript = + Object.keys(initialData || {}).length > 0 + ? `` + : ""; + + console.log("server html"); + + console.log(html); + + // 템플릿 교체 + const finalHtml = template + .replace("", html) + .replace("", head) + .replace("", `${initialDataScript}`); + + console.log(finalHtml); + + console.log("🎉 최종 HTML 생성 완료, 길이:", finalHtml.length); + + res.setHeader("Content-Type", "text/html"); + res.status(200).send(finalHtml); + } catch (renderError) { + console.error("❌ 렌더링 에러:", renderError); + console.error("❌ Stack:", renderError.stack); + + // 에러 발생 시 클라이언트 사이드 렌더링으로 폴백 + const fallbackHtml = template + .replace("", '
') + .replace("", "쇼핑몰") + .replace("", ``); + + res.status(200).send(fallbackHtml); + } + } catch (error) { + console.error("❌ 전체 에러:", error); + console.error("❌ Stack:", error.stack); + + res.status(500).send(` + + 서버 오류 + +

서버 내부 오류

+
+ 에러 상세 +
${error.stack}
+
+ + + `); + } +}); + +// 에러 핸들링 +app.use((error, req, res, next) => { + console.error("🚨 Express 에러 핸들러:", error); + res.status(500).send("서버 내부 오류가 발생했습니다."); +}); + +app.use("/", express.static("dist/react")); + +// 서버 시작 app.listen(port, () => { - console.log(`React Server started at http://localhost:${port}`); + console.log(`🚀 React SSR Server started at http://localhost:${port}`); + console.log(`📁 Base URL: ${base}`); + console.log(`🔧 Environment: ${prod ? "production" : "development"}`); + console.log(`🌐 브라우저에서 http://localhost:${port} 로 접속해보세요!`); }); diff --git a/packages/react/src/App.tsx b/packages/react/src/App.tsx index 36b302ca..38f19f88 100644 --- a/packages/react/src/App.tsx +++ b/packages/react/src/App.tsx @@ -5,20 +5,22 @@ import { ModalProvider, ToastProvider } from "./components"; // 홈 페이지 (상품 목록) router.addRoute("/", HomePage); +router.addRoute("/product/:id", ProductDetailPage); router.addRoute("/product/:id/", ProductDetailPage); -router.addRoute(".*", NotFoundPage); +router.addRoute("*", NotFoundPage); const CartInitializer = () => { - useLoadCartStore(); + if (typeof window !== "undefined") { + useLoadCartStore(); + } return null; }; /** - * 전체 애플리케이션 렌더링 + * 클라이언트 사이드 애플리케이션 */ -export const App = () => { +const ClientApp = () => { const PageComponent = useCurrentPage(); - return ( <> @@ -28,3 +30,35 @@ export const App = () => { ); }; + +/** + * 서버 사이드 애플리케이션 + */ +const ServerApp = ({ url }: { url?: string }) => { + const splitUrl = (url ?? "").split("/").filter((segment) => segment !== ""); + + let PageComponent = HomePage; + + if (splitUrl?.[0] === "product") { + PageComponent = ProductDetailPage; + } else if ((url ?? "").split("?")[0] === "" || url === "/" || url === "") { + PageComponent = HomePage; + } else { + PageComponent = NotFoundPage; + } + + console.log("ServerApp 렌더링:", { url, PageComponent: PageComponent.name }); + + return ( + + + + + + ); +}; + +export const App = ({ url }: { url?: string } = {}) => { + const isServer = typeof window === "undefined"; + return isServer ? : ; +}; diff --git a/packages/react/src/api/productApi.ts b/packages/react/src/api/productApi.ts index 29956155..e4ac6b52 100644 --- a/packages/react/src/api/productApi.ts +++ b/packages/react/src/api/productApi.ts @@ -20,6 +20,8 @@ interface ProductsResponse { }; } +const API_BASE = typeof window !== "undefined" ? window.location.origin : "http://localhost:5176"; // 또는 환경변수로 설정 + export async function getProducts(params: StringRecord = {}): Promise { const { limit = 20, search = "", category1 = "", category2 = "", sort = "price_asc" } = params; const page = params.current ?? params.page ?? 1; @@ -33,19 +35,22 @@ export async function getProducts(params: StringRecord = {}): Promise { - const response = await fetch(`/api/products/${productId}`); + const url = new URL(`/api/products/${productId}`, API_BASE); + const response = await fetch(url); return await response.json(); } // 카테고리 목록 조회 export async function getCategories(): Promise { - const response = await fetch("/api/categories"); + const url = new URL("/api/categories", API_BASE); + const response = await fetch(url); return await response.json(); } diff --git a/packages/react/src/entities/products/components/hooks/useLoadProductDetail.ts b/packages/react/src/entities/products/components/hooks/useLoadProductDetail.ts index 509f166f..0a96b565 100644 --- a/packages/react/src/entities/products/components/hooks/useLoadProductDetail.ts +++ b/packages/react/src/entities/products/components/hooks/useLoadProductDetail.ts @@ -5,6 +5,7 @@ import { loadProductDetailForPage } from "../../productUseCase"; export const useLoadProductDetail = () => { const productId = useRouterParams((params) => params.id); useEffect(() => { + if (typeof window === "undefined") return; loadProductDetailForPage(productId); }, [productId]); }; diff --git a/packages/react/src/entities/products/components/hooks/useProductFilter.ts b/packages/react/src/entities/products/components/hooks/useProductFilter.ts index ab24c1ec..fff2d3dc 100644 --- a/packages/react/src/entities/products/components/hooks/useProductFilter.ts +++ b/packages/react/src/entities/products/components/hooks/useProductFilter.ts @@ -1,19 +1,25 @@ import { useEffect } from "react"; import { useRouterQuery } from "../../../../router"; import { loadProducts } from "../../productUseCase"; +import { useProductStore } from "../../hooks"; export const useProductFilter = () => { + const { filters } = useProductStore(); const { search: searchQuery, limit, sort, category1, category2 } = useRouterQuery(); const category = { category1, category2 }; useEffect(() => { + if (typeof window === "undefined") return; loadProducts(true); }, [searchQuery, limit, sort, category1, category2]); + console.log("ㅅㅂ"); + console.log(filters); + return { - searchQuery, - limit, - sort, - category, + searchQuery: typeof window === "undefined" ? filters.search : searchQuery, + limit: typeof window === "undefined" ? filters.limit : limit, + sort: typeof window === "undefined" ? filters.sort : sort, + category: typeof window === "undefined" ? { category1: filters.category1, category2: filters.category2 } : category, }; }; diff --git a/packages/react/src/entities/products/hooks/useProductStore.ts b/packages/react/src/entities/products/hooks/useProductStore.ts index 8395fe2e..84313008 100644 --- a/packages/react/src/entities/products/hooks/useProductStore.ts +++ b/packages/react/src/entities/products/hooks/useProductStore.ts @@ -1,4 +1,57 @@ import { useStore } from "@hanghae-plus/lib"; -import { productStore } from "../productStore"; +import { PRODUCT_ACTIONS, productStore } from "../productStore"; +import { type PropsWithChildren } from "react"; +import type { Categories, Product } from "../types"; export const useProductStore = () => useStore(productStore); + +interface ProductProviderProps { + initialData?: { + products?: Product[]; + categories?: Categories; + totalCount?: number; + currentProduct?: Product; + relatedProducts?: Product[]; + filters: { + search: string; + limit: string; + sort: string; + category1: string; + category2: string; + }; + }; +} + +export const ProductProvider = ({ children, initialData }: PropsWithChildren) => { + // useEffect 대신 즉시 실행 + + if (initialData?.currentProduct || (initialData?.products && initialData.products.length > 0)) { + // 현재 스토어 상태 확인 + const currentState = productStore.getState(); + console.log("데이터000"); + // console.log(initialData?.currentProduct); + console.log(currentState.products.length); + console.log(currentState.currentProduct); + + // 아직 초기 데이터가 설정되지 않았을 때만 설정 + if (currentState.loading === true || typeof window === "undefined") { + console.log("데이터"); + console.log(initialData?.currentProduct); + + productStore.dispatch({ + type: PRODUCT_ACTIONS.SET_INITIAL_DATA, + payload: { + products: initialData.products, + categories: initialData.categories || {}, + totalCount: initialData.totalCount || (initialData?.products ?? []).length, + currentProduct: initialData.currentProduct || {}, + relatedProducts: initialData.relatedProducts || [], + filters: initialData.filters, + loading: false, + }, + }); + } + } + + return children; +}; diff --git a/packages/react/src/entities/products/productStore.ts b/packages/react/src/entities/products/productStore.ts index 3aae3904..318ab1d1 100644 --- a/packages/react/src/entities/products/productStore.ts +++ b/packages/react/src/entities/products/productStore.ts @@ -21,6 +21,9 @@ export const PRODUCT_ACTIONS = { // status 관리 SET_STATUS: "products/setStatus", + + // ssr 설정 + SET_INITIAL_DATA: "products/setInitialData", } as const; /** @@ -42,6 +45,13 @@ export const initialProductState = { // 카테고리 목록 categories: {} as Categories, + filters: { + search: "", + limit: "", + sort: "", + category1: "", + category2: "", + }, }; /** @@ -118,6 +128,20 @@ const productReducer = (state: typeof initialProductState, action: any) => { case PRODUCT_ACTIONS.SETUP: return { ...state, ...action.payload }; + case PRODUCT_ACTIONS.SET_INITIAL_DATA: + return { + ...state, + products: action.payload.products || [], + categories: action.payload.categories || {}, + totalCount: action.payload.totalCount || 0, + filters: action.payload.filters || {}, + currentProduct: action.payload.currentProduct || null, + relatedProducts: action.payload.relatedProducts || [], + loading: false, + error: null, + status: "done", + }; + default: return state; } diff --git a/packages/react/src/main-server.tsx b/packages/react/src/main-server.tsx index 611b0a58..ff7c8511 100644 --- a/packages/react/src/main-server.tsx +++ b/packages/react/src/main-server.tsx @@ -1,4 +1,149 @@ +// main-server.js 수정된 버전 + +import { renderToString } from "react-dom/server"; +import { Router } from "@hanghae-plus/lib"; +import type { FunctionComponent } from "react"; +import { BASE_URL } from "./constants"; +import { getProducts, getProduct, getCategories } from "./mocks/ssr-data"; +import { HomePage, NotFoundPage, ProductDetailPage } from "./pages"; +import { PRODUCT_ACTIONS, ProductProvider, productStore } from "./entities"; +import { App } from "./App"; + export const render = async (url: string, query: Record) => { - console.log({ url, query }); - return ""; + console.log("SSR render 시작:", { url, query }); + + const serverRouter = new Router(BASE_URL); + + try { + // 라우트 등록 + serverRouter.addRoute("/", HomePage); + serverRouter.addRoute("/product/:id", ProductDetailPage); + serverRouter.addRoute("*", NotFoundPage); + } catch (error) { + console.error("라우터 설정 오류:", error); + } + + const splitUrl = url.split("/").filter((segment) => segment !== ""); + + const initialData = { + products: [], + categories: {}, + totalCount: 0, + loading: false, + error: null, + currentProduct: null, + relatedProducts: [], + filters: {}, + }; + + try { + if (splitUrl?.[0] === "product") { + // 상품 상세 페이지 + const productId = splitUrl[1]; + console.log("상품 상세 페이지 데이터 로딩:", productId); + + try { + const product = await getProduct(productId); + console.log("상품 로딩 성공:", product.title); + + initialData.currentProduct = product; + + // 관련 상품 로딩 + if (product.category2) { + try { + const relatedData = await getProducts({ + category2: product.category2, + limit: "20", + }); + initialData.relatedProducts = relatedData.products.filter((p) => p.productId !== productId); + console.log("관련 상품 로딩 성공:", initialData.relatedProducts.length, "개"); + } catch (relatedError) { + console.error("관련 상품 로딩 실패:", relatedError); + initialData.relatedProducts = []; + } + } + + const categoriesData = await getCategories(); + initialData.categories = categoriesData; + } catch (productError) { + console.error("상품 로딩 실패:", productError); + } + } else { + // 홈페이지 + console.log("홈페이지 데이터 로딩"); + const [productsData, categoriesData] = await Promise.all([getProducts(query), getCategories()]); + + initialData.products = productsData.products; + initialData.categories = categoriesData; + initialData.totalCount = productsData.pagination.total; + initialData.filters = + Object.keys(query).length > 0 + ? { + search: query.search || "", + limit: query.limit || "", + sort: query.sort || "", + category1: query.category1 || "", + category2: query.category2 || "", + } + : { limit: "20", sort: "price_asc" }; + } + + console.log("초기 데이터 준비 완료:", { + productsCount: initialData.products.length, + categoriesCount: Object.keys(initialData.categories).length, + totalCount: initialData.totalCount, + currentProduct: initialData.currentProduct, + relatedCount: initialData.relatedProducts.length, + }); + } catch (error) { + console.error("데이터 로딩 오류:", error); + } + + // React 컴포넌트를 HTML로 렌더링 + let html = ""; + try { + console.log("React 컴포넌트 렌더링 시작"); + + // 스토어 초기화 + productStore.dispatch({ + type: PRODUCT_ACTIONS.SET_INITIAL_DATA, + payload: initialData, + }); + + // 전체 렌더링 + html = renderToString( + + + , + ); + + console.log("전체 렌더링 완료, HTML 길이:", html.length); + + if (html.length === 0) { + console.error("⚠️ 렌더링된 HTML이 비어있습니다!"); + // 폴백 HTML + html = `
`; + } + } catch (renderError) { + console.error("React 렌더링 오류:", renderError); + html = `
`; + } + + const head = `${initialData.currentProduct ? `${initialData.currentProduct?.title} - 쇼핑몰` : "쇼핑몰 - 홈"}`; + + console.log("SSR render 완료"); + + return { + html, + head, + initialData, + }; +}; + +export const debugSSR = () => { + console.log("=== SSR 디버깅 정보 ==="); + console.log("renderToString 함수:", typeof renderToString); + console.log("App 컴포넌트:", typeof App); + console.log("ProductProvider:", typeof ProductProvider); + console.log("BASE_URL:", BASE_URL); }; diff --git a/packages/react/src/main.tsx b/packages/react/src/main.tsx index 0c5b8a67..6a07c232 100644 --- a/packages/react/src/main.tsx +++ b/packages/react/src/main.tsx @@ -1,28 +1,47 @@ import { App } from "./App"; import { router } from "./router"; import { BASE_URL } from "./constants.ts"; -import { createRoot } from "react-dom/client"; +import { createRoot, hydrateRoot } from "react-dom/client"; +import { ProductProvider } from "./entities/index.ts"; -const enableMocking = () => - import("./mocks/browser").then(({ worker }) => - worker.start({ - serviceWorker: { - url: `${BASE_URL}mockServiceWorker.js`, - }, - onUnhandledRequest: "bypass", - }), - ); +const enableMocking = async () => { + // 브라우저 환경에서만 MSW 실행 + if (typeof window === "undefined") { + return Promise.resolve(); + } + + const { worker } = await import("./mocks/browser"); + return await worker.start({ + serviceWorker: { + url: `${BASE_URL}mockServiceWorker.js`, + }, + onUnhandledRequest: "bypass", + }); +}; function main() { router.start(); const rootElement = document.getElementById("root")!; - createRoot(rootElement).render(); + if (typeof window !== "undefined") { + const initData = window?.__INITIAL_DATA__; + + hydrateRoot( + rootElement, + + + , + ); + } else { + createRoot(rootElement).render(); + } } // 애플리케이션 시작 -if (import.meta.env.MODE !== "test") { - enableMocking().then(main); -} else { - main(); +if (typeof window !== "undefined") { + if (import.meta.env.MODE !== "test") { + enableMocking().then(main); + } else { + main(); + } } diff --git a/packages/react/src/mocks/ssr-data.ts b/packages/react/src/mocks/ssr-data.ts new file mode 100644 index 00000000..343f40cf --- /dev/null +++ b/packages/react/src/mocks/ssr-data.ts @@ -0,0 +1,105 @@ +import items from "../mocks/items.json" with { type: "json" }; +import type { Categories, Product } from "../entities"; +import type { StringRecord } from "../types.ts"; + +interface ProductsResponse { + products: Product[]; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + hasNext: boolean; + hasPrev: boolean; + }; + filters: { + search: string; + category1: string; + category2: string; + sort: string; + }; +} + +// 상품 목록 필터링 +function filterProducts(products: Product[], query: StringRecord): Product[] { + let filtered = [...products]; + + if (query.search) { + const searchTerm = query.search.toLowerCase(); + filtered = filtered.filter( + (item) => item.title.toLowerCase().includes(searchTerm) || item.brand.toLowerCase().includes(searchTerm), + ); + } + + if (query.category1) { + filtered = filtered.filter((item) => item.category1 === query.category1); + } + if (query.category2) { + filtered = filtered.filter((item) => item.category2 === query.category2); + } + + if (query.sort) { + switch (query.sort) { + case "price_asc": + filtered.sort((a, b) => parseInt(a.lprice) - parseInt(b.lprice)); + break; + case "price_desc": + filtered.sort((a, b) => parseInt(b.lprice) - parseInt(a.lprice)); + break; + case "name_asc": + filtered.sort((a, b) => a.title.localeCompare(b.title, "ko")); + break; + case "name_desc": + filtered.sort((a, b) => b.title.localeCompare(a.title, "ko")); + break; + default: + filtered.sort((a, b) => parseInt(a.lprice) - parseInt(b.lprice)); + } + } + + return filtered; +} + +// 상품 목록 조회 +export async function getProducts(params: StringRecord = {}): Promise { + const { limit = 20, search = "", category1 = "", category2 = "", sort = "price_asc" } = params; + const page = params.current ?? params.page ?? 1; + + const filteredProducts = filterProducts(items, { search, category1, category2, sort }); + + const startIndex = (Number(page) - 1) * Number(limit); + const endIndex = startIndex + Number(limit); + const paginatedProducts = filteredProducts.slice(startIndex, endIndex); + + return { + products: paginatedProducts, + pagination: { + page: Number(page), + limit: Number(limit), + total: filteredProducts.length, + totalPages: Math.ceil(filteredProducts.length / Number(limit)), + hasNext: endIndex < filteredProducts.length, + hasPrev: Number(page) > 1, + }, + filters: { search, category1, category2, sort }, + }; +} + +// 상품 상세 조회 +export async function getProduct(productId: string): Promise { + const product = items.find((item) => item.productId === productId); + if (!product) throw new Error("Product not found"); + return product; +} + +// 카테고리 목록 조회 +export async function getCategories(): Promise { + const categories: Categories = {}; + items.forEach((item) => { + const cat1 = item.category1; + const cat2 = item.category2; + if (!categories[cat1]) categories[cat1] = {}; + if (cat2 && !categories[cat1][cat2]) categories[cat1][cat2] = {}; + }); + return categories; +} diff --git a/packages/react/src/pages/HomePage.tsx b/packages/react/src/pages/HomePage.tsx index 4edbccc6..f14838a7 100644 --- a/packages/react/src/pages/HomePage.tsx +++ b/packages/react/src/pages/HomePage.tsx @@ -29,7 +29,9 @@ const unregisterScrollHandler = () => { export const HomePage = () => { useEffect(() => { registerScrollHandler(); - loadProductsAndCategories(); + if (!window.__INITIAL_DATA__) { + loadProductsAndCategories(); + } return unregisterScrollHandler; }, []); diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index a65dba85..b78aa622 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -1,2 +1,19 @@ +import type { Categories, Product } from "./entities"; + export type StringRecord = Record; export type AnyFunction = (...args: unknown[]) => unknown; + +declare global { + interface Window { + __INITIAL_DATA__?: { + products?: Product[]; + categories?: Categories; + totalCount?: number; + loading?: boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + error?: any; + currentProduct?: Product | null; + relatedProducts?: Product[]; + }; + } +} diff --git a/packages/react/src/utils/log.ts b/packages/react/src/utils/log.ts index 00aa2d47..742e6178 100644 --- a/packages/react/src/utils/log.ts +++ b/packages/react/src/utils/log.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ + declare global { interface Window { __spyCalls: any[]; @@ -6,12 +7,17 @@ declare global { } } -window.__spyCalls = []; -window.__spyCallsClear = () => { +if (typeof window !== "undefined") { window.__spyCalls = []; -}; + window.__spyCallsClear = () => { + window.__spyCalls = []; + }; +} export const log: typeof console.log = (...args) => { - window.__spyCalls.push(args); - return console.log(...args); + if (typeof window !== "undefined") { + window.__spyCalls.push(args); + console.log(...args); + } + return console.log(); }; diff --git a/packages/react/static-site-generate.js b/packages/react/static-site-generate.js index 145c957b..2d925f89 100644 --- a/packages/react/static-site-generate.js +++ b/packages/react/static-site-generate.js @@ -1,18 +1,219 @@ -import { renderToString } from "react-dom/server"; -import { createElement } from "react"; import fs from "fs"; +import path from "path"; + +// items.json 데이터 로드 (React 버전에 맞게 수정) +async function loadItemsData() { + try { + const itemsModule = await import("./src/mocks/items.json", { with: { type: "json" } }); + return itemsModule.default; + } catch (error) { + console.error("Failed to load items.json:", error); + return []; + } +} + +// 카테고리 추출 +function getUniqueCategories(itemsList) { + const categories = {}; + itemsList.forEach((item) => { + const cat1 = item.category1; + const cat2 = item.category2; + if (!categories[cat1]) categories[cat1] = {}; + if (cat2 && !categories[cat1][cat2]) categories[cat1][cat2] = {}; + }); + return categories; +} + +// 상품 필터링 및 정렬 +function filterAndSortProducts(products) { + return [...products].sort((a, b) => parseInt(a.lprice) - parseInt(b.lprice)); +} + +// 관련 상품 찾기 +function getRelatedProducts(items, product, limit = 20) { + if (!product.category2) return []; + + return items + .filter((item) => item.category2 === product.category2 && item.productId !== product.productId) + .slice(0, limit); +} + +// React 서버사이드 렌더링 함수들 import +async function getReactRenderFunctions() { + try { + // React SSR 빌드된 파일에서 렌더링 함수 가져오기 + const ssrModulePath = `./dist/react-ssr/main-server.js`; + const { render } = await import(ssrModulePath); + return { render }; + } catch (error) { + console.error("Failed to import React server render functions:", error); + + // 대안: 직접 React 렌더링 함수 생성 + try { + const { renderToString } = await import("react-dom/server"); + const { createElement } = await import("react"); + const { ProductProvider } = await import("./src/entities/products/productStore.js"); + + const simpleRender = (url, params, initialData, options) => { + try { + // 간단한 렌더링 로직 (App 컴포넌트 없이) + const html = renderToString( + createElement( + ProductProvider, + { initialData }, + createElement("div", { id: "root" }, ""), + ), + ); + + return { + html, + head: `${initialData.currentProduct ? `${initialData.currentProduct.title} - 쇼핑몰` : "쇼핑몰 - 홈"}`, + }; + } catch (renderError) { + console.error("Render error:", renderError); + return { + html: '
', + head: "쇼핑몰", + }; + } + }; + + return { render: simpleRender }; + } catch (fallbackError) { + console.error("Fallback render creation failed:", fallbackError); + return { render: null }; + } + } +} async function generateStaticSite() { + console.log("🚀 Starting React SSG generation..."); + + // 데이터 로드 + const items = await loadItemsData(); + const categories = getUniqueCategories(items); + const sortedProducts = filterAndSortProducts(items); + + console.log(`📦 Loaded ${items.length} products`); + + // React 서버 렌더링 함수 가져오기 + const { render } = await getReactRenderFunctions(); + + if (!render) { + throw new Error("React server render function not available"); + } + // HTML 템플릿 읽기 - const template = fs.readFileSync("../../dist/react/index.html", "utf-8"); + const templatePath = path.resolve("../../dist/react/index.html"); + const template = fs.readFileSync(templatePath, "utf-8"); + + // 1. 홈페이지 생성 + console.log("🏠 Generating homepage..."); + const homeInitialData = { + products: sortedProducts.slice(0, 20), // 첫 20개 상품 + categories, + totalCount: items.length, + loading: false, + error: null, + currentProduct: null, + relatedProducts: [], + filters: { limit: "20", sort: "price_asc" }, + }; + + const homeRendered = await render("/", {}, homeInitialData, { doSSR: true }); + const homeHtml = template + .replace(``, homeRendered.head || "") + .replace(``, homeRendered.html || "") + .replace( + ``, + ``, + ); + + fs.writeFileSync("../../dist/react/index.html", homeHtml); + console.log("✅ Homepage generated"); + + // 2. 상품 상세 페이지들 생성 + console.log("📋 Generating product detail pages..."); + + // product 디렉토리 생성 + const productBaseDir = "../../dist/react/product"; + if (!fs.existsSync(productBaseDir)) { + fs.mkdirSync(productBaseDir, { recursive: true }); + } + + // 각 상품에 대해 상세 페이지 생성 + for (let i = 0; i < items.length; i++) { + const product = items[i]; + const productId = product.productId; + const productDir = `../../dist/react/product/${productId}`; + + // 상품별 디렉토리 생성 + if (!fs.existsSync(productDir)) { + fs.mkdirSync(productDir, { recursive: true }); + } - // 어플리케이션 렌더링하기 - const appHtml = renderToString(createElement("div", null, "안녕하세요")); + // 상품 상세 데이터 준비 (vanilla 버전과 동일) + const enhancedProduct = { + ...product, + description: `${product.title}에 대한 상세 설명입니다. ${product.brand} 브랜드의 우수한 품질을 자랑하는 상품으로, 고객 만족도가 높은 제품입니다.`, + rating: Math.floor(Math.random() * 2) + 4, + reviewCount: Math.floor(Math.random() * 1000) + 50, + stock: Math.floor(Math.random() * 100) + 10, + images: [product.image, product.image.replace(".jpg", "_2.jpg"), product.image.replace(".jpg", "_3.jpg")], + }; - // 결과 HTML 생성하기 - const result = template.replace("", appHtml); - fs.writeFileSync("../../dist/react/index.html", result); + const relatedProducts = getRelatedProducts(items, product); + + const productInitialData = { + products: [], + categories, + totalCount: 0, + loading: false, + error: null, + currentProduct: enhancedProduct, + relatedProducts, + filters: {}, + }; + + const productRendered = await render(`/product/${productId}/`, {}, productInitialData, { doSSR: true }); + const productHtml = template + .replace(``, productRendered.head || "") + .replace(``, productRendered.html || "") + .replace(/.*?<\/title>/, `<title>${enhancedProduct.title} - 쇼핑몰`) + .replace( + ``, + ``, + ); + + fs.writeFileSync(`${productDir}/index.html`, productHtml); + + // 진행률 출력 (50개마다) + if (i % 50 === 0) { + console.log(`📋 Generated ${i + 1}/${items.length} product pages...`); + } + } + + console.log(`✅ Generated ${items.length} product detail pages`); + console.log("🎉 React SSG generation completed!"); +} + +// 디버깅용 함수 (기존 코드에서 가져옴) +function debugStaticGeneration() { + console.log("=== React 정적 사이트 생성기 디버깅 정보 ==="); + console.log("현재 작업 디렉토리:", process.cwd()); + console.log("Node.js 버전:", process.version); } // 실행 -generateStaticSite(); +if (import.meta.url === new URL(import.meta.url).href) { + debugStaticGeneration(); + generateStaticSite().catch(console.error); +} + +export { generateStaticSite, debugStaticGeneration }; diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index b9a56d98..7e56ca0e 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -1,4 +1,7 @@ import express from "express"; +import fs from "fs"; +import path from "path"; +import { loadingProudcts, filterProducts } from "./src/utils/productUtils.js"; const prod = process.env.NODE_ENV === "production"; const port = process.env.PORT || 5173; @@ -6,29 +9,180 @@ const base = process.env.BASE || (prod ? "/front_6th_chapter4-1/vanilla/" : "/") const app = express(); -const render = () => { - return `
안녕하세요
`; -}; - -app.get("*all", (req, res) => { - res.send( - ` - - - - - - Vanilla Javascript SSR - - -
${render()}
- - - `.trim(), - ); +const templateHtml = prod ? fs.readFileSync("./dist/vanilla/index.html", "utf-8") : ""; + +async function loadItemsFromDist() { + const distDir = path.resolve(process.cwd(), "dist/vanilla-ssr/assets/"); + if (!fs.existsSync(distDir)) return []; + + const file = fs.readdirSync(distDir).find((f) => f.startsWith("items") && f.endsWith(".js")); + if (!file) return []; + + const js = fs.readFileSync(path.join(distDir, file), "utf-8"); + const m = js.match(/items_default\s*=\s*.*?JSON\.parse\("([\s\S]+?)"\)/); + if (!m) return []; + + const unescaped = m[1].replace(/\\"/g, '"').replace(/\\\\/g, "\\"); + return JSON.parse(unescaped); +} + +let vite; +if (!prod) { + const { createServer } = await import("vite"); + vite = await createServer({ + server: { middlewareMode: true }, + appType: "custom", + base, + }); + app.use(vite.middlewares); +} else { + const compression = (await import("compression")).default; + const sirv = (await import("sirv")).default; + app.use(compression()); + app.use(base, sirv("./dist/vanilla", { extensions: [] })); +} + +app.use("*all", async (req, res) => { + try { + const url = req.originalUrl.replace(base, ""); + + let template; + let render; + if (!prod) { + template = fs.readFileSync("./index.html", "utf-8"); + template = await vite.transformIndexHtml(url, template); + render = (await vite.ssrLoadModule("./src/main-server.js")).render; + } else { + template = templateHtml; + render = (await import("./dist/vanilla-ssr/main-server.js")).render; + } + + // URL에서 쿼리 파라미터 파싱 + const urlObj = new URL(url, `http://localhost:${port}`); + const query = {}; + + for (const [key, value] of urlObj.searchParams) { + query[key] = value; + } + + // 포트로 SSR/CSR 결정: 4173 → SSR, 5173 → CSR (그 외는 SSR) + const hostHeader = req.headers.host || ""; + const reqPort = Number(hostHeader.split(":")[1] || (req.socket?.localPort ?? 0)); + const doSSR = !["5173", "4173"].includes(reqPort); + + // 서버에서 필요한 데이터 미리 로드 + const initialData = { + products: [], + categories: {}, + totalCount: 0, + loading: false, + error: null, + currentProduct: null, + relatedProducts: [], + }; + + try { + // URL에 따라 다른 데이터 로드 + if (doSSR) { + // 기존 API 함수들 import (서버에서도 작동하도록 수정된 버전) + + const splitUrl = url.split("/").filter((segment) => segment !== ""); + + if (!prod) { + const { getProducts, getCategories, getProduct } = await vite.ssrLoadModule("./src/api/productApi.js"); + + if (splitUrl?.[0] === "product") { + // 상품 상세 페이지: 상품 상세와 관련 상품 로드 + const productId = splitUrl[1]; + + const product = await getProduct(productId); + initialData.currentProduct = product; + + // 관련 상품 로드 (같은 category2) + if (product.category2) { + const relatedData = await getProducts({ + category2: product.category2, + limit: 20, + }); + initialData.relatedProducts = relatedData.products.filter((p) => p.productId !== productId); + } + + const categoriesData = await getCategories(); + initialData.categories = categoriesData; + } else if (url === "" || url === "/" || url.startsWith("/?") || url.startsWith("?")) { + // 홈페이지: 상품 목록과 카테고리 로드 + const [productsData, categoriesData] = await Promise.all([getProducts(query), getCategories()]); + + initialData.products = productsData.products; + initialData.categories = categoriesData; + initialData.totalCount = productsData.pagination.total; + } + } else { + const items = await loadItemsFromDist(); // ← 이미 파일 상단에 추가한 헬퍼 함수 + + const buildCategories = (arr) => { + const cats = {}; + for (const it of arr) { + if (!it.category1) continue; + cats[it.category1] ??= {}; + if (it.category2) cats[it.category1][it.category2] ??= {}; + } + return cats; + }; + + if (splitUrl?.[0] === "product") { + const productId = splitUrl[1]; + const product = items.find((i) => String(i.productId) === String(productId)) || null; + initialData.currentProduct = product; + + if (product?.category2) { + initialData.relatedProducts = items + .filter((i) => i.category2 === product.category2 && String(i.productId) !== String(productId)) + .slice(0, 20); + } + initialData.categories = buildCategories(items); + } else if (url === "" || url === "/" || url.startsWith("?")) { + // 홈: 테스트가 기대하는 초기 데이터 + const limit = Number(query.limit) > 0 ? Number(query.limit) : 20; + const products = Object.keys(query).length === 0 ? loadingProudcts(items) : filterProducts(items, query); + + // 초기 데이터 채우기 + initialData.products = products.slice(0, limit); + initialData.totalCount = products.length; // 340 + initialData.categories = buildCategories(items); + } + } + } + } catch (error) { + console.error("서버 데이터 로드 실패:", error); + initialData.error = error.message; + } + + console.log(url); + + const rendered = doSSR ? await render(url, query, initialData, { doSSR }) : { head: "", html: "" }; + + // 초기 데이터를 HTML에 주입 + const html = template + .replace(``, rendered.head ?? "") + .replace(``, rendered.html ?? "") + .replace( + ``, + ``, + ); + + res.status(200).set({ "Content-Type": "text/html" }).end(html); + } catch (e) { + vite?.ssrFixStacktrace(e); + console.log(e.stack); + res.status(500).end(e.stack); + } }); // Start http server app.listen(port, () => { - console.log(`React Server started at http://localhost:${port}`); + console.log(`SSR Server started at http://localhost:${port}`); }); diff --git a/packages/vanilla/src/api/productApi.js b/packages/vanilla/src/api/productApi.js index c2330fbe..1628a5cb 100644 --- a/packages/vanilla/src/api/productApi.js +++ b/packages/vanilla/src/api/productApi.js @@ -1,3 +1,146 @@ +import { isServer } from "../utils"; + +// MSW 핸들러를 서버에서도 직접 사용하기 위한 캐시 +// let serverHandlers = null; +let itemsData = null; + +// 서버 환경에서 items.json 데이터 로드 +async function getItemsData() { + if (!itemsData && isServer) { + try { + const itemsModule = await import("../mocks/items.json", { with: { type: "json" } }); + itemsData = itemsModule.default; + } catch (error) { + console.error("Failed to load items.json:", error); + itemsData = []; + } + } + return itemsData || []; +} + +// 카테고리 추출 함수 +function getUniqueCategories(itemsList) { + const categories = {}; + + itemsList.forEach((item) => { + const cat1 = item.category1; + const cat2 = item.category2; + + if (!categories[cat1]) categories[cat1] = {}; + if (cat2 && !categories[cat1][cat2]) categories[cat1][cat2] = {}; + }); + + return categories; +} + +// 상품 검색 및 필터링 함수 +function filterProducts(products, query) { + let filtered = [...products]; + + // 검색어 필터링 + if (query.search) { + const searchTerm = query.search.toLowerCase(); + filtered = filtered.filter( + (item) => item.title.toLowerCase().includes(searchTerm) || item.brand.toLowerCase().includes(searchTerm), + ); + } + + // 카테고리 필터링 + if (query.category1) { + filtered = filtered.filter((item) => item.category1 === query.category1); + } + if (query.category2) { + filtered = filtered.filter((item) => item.category2 === query.category2); + } + + // 정렬 + if (query.sort) { + switch (query.sort) { + case "price_asc": + filtered.sort((a, b) => parseInt(a.lprice) - parseInt(b.lprice)); + break; + case "price_desc": + filtered.sort((a, b) => parseInt(b.lprice) - parseInt(a.lprice)); + break; + case "name_asc": + filtered.sort((a, b) => a.title.localeCompare(b.title, "ko")); + break; + case "name_desc": + filtered.sort((a, b) => b.title.localeCompare(a.title, "ko")); + break; + default: + // 기본은 가격 낮은 순 + filtered.sort((a, b) => parseInt(a.lprice) - parseInt(b.lprice)); + } + } + + return filtered; +} + +// 서버 환경에서 MSW 핸들러 로직을 직접 실행 +async function serverGetProducts(params = {}) { + const itemsList = await getItemsData(); + const { limit = 20, search = "", category1 = "", category2 = "", sort = "price_asc" } = params; + const page = params.current ?? params.page ?? 1; + + // 필터링된 상품들 + const filteredProducts = filterProducts(itemsList, { + search, + category1, + category2, + sort, + }); + + // 페이지네이션 + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit; + const paginatedProducts = filteredProducts.slice(startIndex, endIndex); + + // 응답 데이터 + return { + products: paginatedProducts, + pagination: { + page, + limit, + total: filteredProducts.length, + totalPages: Math.ceil(filteredProducts.length / limit), + hasNext: endIndex < filteredProducts.length, + hasPrev: page > 1, + }, + filters: { + search, + category1, + category2, + sort, + }, + }; +} + +async function serverGetProduct(productId) { + const itemsList = await getItemsData(); + const product = itemsList.find((item) => item.productId === productId); + + if (!product) { + throw new Error("Product not found"); + } + + // 상세 정보에 추가 데이터 포함 + return { + ...product, + description: `${product.title}에 대한 상세 설명입니다. ${product.brand} 브랜드의 우수한 품질을 자랑하는 상품으로, 고객 만족도가 높은 제품입니다.`, + rating: Math.floor(Math.random() * 2) + 4, // 4~5점 랜덤 + reviewCount: Math.floor(Math.random() * 1000) + 50, // 50~1050개 랜덤 + stock: Math.floor(Math.random() * 100) + 10, // 10~110개 랜덤 + images: [product.image, product.image.replace(".jpg", "_2.jpg"), product.image.replace(".jpg", "_3.jpg")], + }; +} + +async function serverGetCategories() { + const itemsList = await getItemsData(); + return getUniqueCategories(itemsList); +} + +// 공용 API 함수들 export async function getProducts(params = {}) { const { limit = 20, search = "", category1 = "", category2 = "", sort = "price_asc" } = params; const page = params.current ?? params.page ?? 1; @@ -11,17 +154,34 @@ export async function getProducts(params = {}) { sort, }); - const response = await fetch(`/api/products?${searchParams}`); - - return await response.json(); + if (isServer) { + // 서버 환경: 직접 로직 실행 + return await serverGetProducts(params); + } else { + // 클라이언트 환경: fetch 사용 + const response = await fetch(`/api/products?${searchParams}`); + return await response.json(); + } } export async function getProduct(productId) { - const response = await fetch(`/api/products/${productId}`); - return await response.json(); + if (isServer) { + // 서버 환경: 직접 로직 실행 + return await serverGetProduct(productId); + } else { + // 클라이언트 환경: fetch 사용 + const response = await fetch(`/api/products/${productId}`); + return await response.json(); + } } export async function getCategories() { - const response = await fetch("/api/categories"); - return await response.json(); + if (isServer) { + // 서버 환경: 직접 로직 실행 + return await serverGetCategories(); + } else { + // 클라이언트 환경: fetch 사용 + const response = await fetch("/api/categories"); + return await response.json(); + } } diff --git a/packages/vanilla/src/lib/Router.js b/packages/vanilla/src/lib/Router.js index 2238a878..4fbd3487 100644 --- a/packages/vanilla/src/lib/Router.js +++ b/packages/vanilla/src/lib/Router.js @@ -2,22 +2,27 @@ * 간단한 SPA 라우터 */ import { createObserver } from "./createObserver.js"; +import { isServer } from "../utils"; export class Router { #routes; #route; #observer = createObserver(); #baseUrl; + #isServer = isServer; constructor(baseUrl = "") { this.#routes = new Map(); this.#route = null; this.#baseUrl = baseUrl.replace(/\/$/, ""); - window.addEventListener("popstate", () => { - this.#route = this.#findRoute(); - this.#observer.notify(); - }); + // SSR 환경에서는 브라우저 API 사용하지 않음 + if (!this.#isServer) { + window.addEventListener("popstate", () => { + this.#route = this.#findRoute(); + this.#observer.notify(); + }); + } } get baseUrl() { @@ -25,10 +30,16 @@ export class Router { } get query() { + if (this.#isServer) { + return {}; + } return Router.parseQuery(window.location.search); } set query(newQuery) { + if (this.#isServer) { + return; + } const newUrl = Router.getUrl(newQuery, this.#baseUrl); this.push(newUrl); } @@ -73,8 +84,22 @@ export class Router { }); } - #findRoute(url = window.location.pathname) { - const { pathname } = new URL(url, window.location.origin); + #findRoute(url) { + if (this.#isServer) { + // SSR에서는 기본 라우트 반환 + const defaultRoute = this.#routes.get("/"); + if (defaultRoute) { + return { + ...defaultRoute, + params: {}, + path: "/", + }; + } + return null; + } + + const actualUrl = url || window.location.pathname; + const { pathname } = new URL(actualUrl, window.location.origin); for (const [routePath, route] of this.#routes) { const match = pathname.match(route.regex); if (match) { @@ -99,6 +124,10 @@ export class Router { * @param {string} url - 이동할 경로 */ push(url) { + if (this.#isServer) { + return; + } + try { // baseUrl이 없으면 자동으로 붙여줌 let fullUrl = url.startsWith(this.#baseUrl) ? url : this.#baseUrl + (url.startsWith("/") ? url : "/" + url); @@ -130,8 +159,13 @@ export class Router { * @param {string} search - location.search 또는 쿼리 문자열 * @returns {Object} 파싱된 쿼리 객체 */ - static parseQuery = (search = window.location.search) => { - const params = new URLSearchParams(search); + static parseQuery = (search) => { + if (isServer) { + return {}; + } + + const actualSearch = search || window.location.search; + const params = new URLSearchParams(actualSearch); const query = {}; for (const [key, value] of params) { query[key] = value; @@ -155,6 +189,10 @@ export class Router { }; static getUrl = (newQuery, baseUrl = "") => { + if (isServer) { + return "/"; + } + const currentQuery = Router.parseQuery(); const updatedQuery = { ...currentQuery, ...newQuery }; diff --git a/packages/vanilla/src/lib/createStorage.js b/packages/vanilla/src/lib/createStorage.js index 08b504f2..29eff72a 100644 --- a/packages/vanilla/src/lib/createStorage.js +++ b/packages/vanilla/src/lib/createStorage.js @@ -1,10 +1,12 @@ +import { safeLocalStorage } from "../utils/ssrUtils"; + /** * 로컬스토리지 추상화 함수 * @param {string} key - 스토리지 키 * @param {Storage} storage - 기본값은 localStorage * @returns {Object} { get, set, reset } */ -export const createStorage = (key, storage = window.localStorage) => { +export const createStorage = (key, storage = safeLocalStorage) => { const get = () => { try { const item = storage.getItem(key); diff --git a/packages/vanilla/src/main-server.js b/packages/vanilla/src/main-server.js index 40b58858..5d5b9d57 100644 --- a/packages/vanilla/src/main-server.js +++ b/packages/vanilla/src/main-server.js @@ -1,4 +1,26 @@ -export const render = async (url, query) => { - console.log({ url, query }); - return ""; +import { HomePage, HomePageSSR } from "./pages/HomePage.js"; +import { ProductDetailPageSSR, ProductDetailPage } from "./pages/ProductDetailPage.js"; + +/** + * @param {string} url + * @param {Record} query + * @param {any} _initialData + * @param {{ doSSR?: boolean }} ctx // ← 서버가 넘겨주는 컨텍스트 + */ +export const render = async (url, query, _initialData, ctx = {}) => { + const doSSR = ctx.doSSR ?? true; // 기본값: SSR + + if (url.split("/").filter((segment) => segment !== "")?.[0] === "product") { + const title = _initialData.currentProduct.title; + + return { + head: `${title ? `${title} - 쇼핑몰` : "쇼핑몰 - 홈"}`, + html: doSSR ? ProductDetailPageSSR({ initialData: _initialData }) : ProductDetailPage({ url, query }), + }; + } + + return { + head: "쇼핑몰 - 홈", + html: doSSR ? HomePageSSR({ url, query, initialData: _initialData }) : HomePage({ url, query }), + }; }; diff --git a/packages/vanilla/src/main.js b/packages/vanilla/src/main.js index 4c3f2765..69b3cb59 100644 --- a/packages/vanilla/src/main.js +++ b/packages/vanilla/src/main.js @@ -4,6 +4,7 @@ import { registerAllEvents } from "./events"; import { loadCartFromStorage } from "./services"; import { router } from "./router"; import { BASE_URL } from "./constants.js"; +import { productStore, PRODUCT_ACTIONS } from "./stores"; const enableMocking = () => import("./mocks/browser.js").then(({ worker }) => @@ -15,16 +16,51 @@ const enableMocking = () => }), ); +// 서버에서 전달받은 초기 데이터를 스토어에 설정 +function hydrateInitialData() { + if (typeof window !== "undefined" && window.__INITIAL_DATA__) { + const initialData = window.__INITIAL_DATA__; + + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: { + products: initialData.products || [], + categories: initialData.categories || {}, + totalCount: initialData.totalCount || 0, + loading: false, + error: null, + status: "done", + }, + }); + + // 초기 데이터 사용 후 삭제 + delete window.__INITIAL_DATA__; + } +} + function main() { registerAllEvents(); registerGlobalEvents(); - loadCartFromStorage(); + + // SSR 환경에서는 브라우저 전용 기능들을 건너뛰기 + if (typeof window !== "undefined") { + // 서버에서 받은 데이터로 하이드레이션 + hydrateInitialData(); + + // 장바구니 데이터 로드 + loadCartFromStorage(); + } + initRender(); router.start(); } if (import.meta.env.MODE !== "test") { - enableMocking().then(main); + if (typeof window !== "undefined") { + enableMocking().then(main); + } else { + main(); + } } else { main(); } diff --git a/packages/vanilla/src/mocks/handlers.js b/packages/vanilla/src/mocks/handlers.js index 6e3035e6..5a8c404d 100644 --- a/packages/vanilla/src/mocks/handlers.js +++ b/packages/vanilla/src/mocks/handlers.js @@ -1,13 +1,31 @@ import { http, HttpResponse } from "msw"; -import items from "./items.json"; +import { isServer } from "../utils"; + +// 서버 환경에서는 동적 import로 items.json 로드 +let items = null; + +async function getItems() { + if (!items) { + if (isServer) { + // 서버 환경 + const itemsModule = await import("./items.json", { with: { type: "json" } }); + items = itemsModule.default; + } else { + // 클라이언트 환경 (이미 정적으로 로드됨) + const itemsModule = await import("./items.json"); + items = itemsModule.default; + } + } + return items; +} const delay = async () => await new Promise((resolve) => setTimeout(resolve, 200)); // 카테고리 추출 함수 -function getUniqueCategories() { +function getUniqueCategories(itemsList) { const categories = {}; - items.forEach((item) => { + itemsList.forEach((item) => { const cat1 = item.category1; const cat2 = item.category2; @@ -65,6 +83,7 @@ function filterProducts(products, query) { export const handlers = [ // 상품 목록 API http.get("/api/products", async ({ request }) => { + const itemsList = await getItems(); const url = new URL(request.url); const page = parseInt(url.searchParams.get("page") ?? url.searchParams.get("current")) || 1; const limit = parseInt(url.searchParams.get("limit")) || 20; @@ -74,7 +93,7 @@ export const handlers = [ const sort = url.searchParams.get("sort") || "price_asc"; // 필터링된 상품들 - const filteredProducts = filterProducts(items, { + const filteredProducts = filterProducts(itemsList, { search, category1, category2, @@ -105,15 +124,19 @@ export const handlers = [ }, }; - await delay(); + // 서버 환경에서는 delay 건너뛰기 + if (typeof window !== "undefined") { + await delay(); + } return HttpResponse.json(response); }), // 상품 상세 API - http.get("/api/products/:id", ({ params }) => { + http.get("/api/products/:id", async ({ params }) => { + const itemsList = await getItems(); const { id } = params; - const product = items.find((item) => item.productId === id); + const product = itemsList.find((item) => item.productId === id); if (!product) { return HttpResponse.json({ error: "Product not found" }, { status: 404 }); @@ -134,8 +157,14 @@ export const handlers = [ // 카테고리 목록 API http.get("/api/categories", async () => { - const categories = getUniqueCategories(); - await delay(); + const itemsList = await getItems(); + const categories = getUniqueCategories(itemsList); + + // 서버 환경에서는 delay 건너뛰기 + if (typeof window !== "undefined") { + await delay(); + } + return HttpResponse.json(categories); }), ]; diff --git a/packages/vanilla/src/pages/HomePage.js b/packages/vanilla/src/pages/HomePage.js index ca08c26c..27e55da5 100644 --- a/packages/vanilla/src/pages/HomePage.js +++ b/packages/vanilla/src/pages/HomePage.js @@ -48,3 +48,41 @@ export const HomePage = withLifecycle( }); }, ); + +export const HomePageSSR = ({ url, query, initialData = {} }) => { + const urlParams = new URLSearchParams(url.split("?")[1] || ""); + console.log(query); + + const search = urlParams.get("search") || ""; + const products = initialData.products ?? []; + const categories = initialData.categories ?? {}; + const totalCount = initialData.totalCount ?? products.length; + const category = { category1: urlParams.get("category1") ?? "", category2: urlParams.get("category2") ?? "" }; + + return PageWrapper({ + headerLeft: ` +

+ 쇼핑몰 +

+ `.trim(), + children: ` + + ${SearchBar({ searchQuery: search, limit: urlParams.get("limit") ?? "", sort: urlParams.get("sort") ?? "", category, categories })} + + +
+
+ 총 3개의 상품 +
+
+ ${ProductList({ + products: products, + loading: false, + error: null, + totalCount, + hasMore: false, + })}
+
+ `.trim(), + }); +}; diff --git a/packages/vanilla/src/pages/ProductDetailPage.js b/packages/vanilla/src/pages/ProductDetailPage.js index 73d0ec30..bfcae5b5 100644 --- a/packages/vanilla/src/pages/ProductDetailPage.js +++ b/packages/vanilla/src/pages/ProductDetailPage.js @@ -264,3 +264,23 @@ export const ProductDetailPage = withLifecycle( }); }, ); + +export const ProductDetailPageSSR = ({ initialData = {} }) => { + return PageWrapper({ + headerLeft: ` +
+ +

상품 상세

+
+ `.trim(), + children: + initialData.error && !initialData.currentProduct + ? ErrorContent({ error: initialData.error }) + : ProductDetail({ product: initialData.currentProduct, relatedProducts: initialData.relatedProducts }), + }); +}; diff --git a/packages/vanilla/src/utils/index.js b/packages/vanilla/src/utils/index.js index b0495013..5eae3548 100644 --- a/packages/vanilla/src/utils/index.js +++ b/packages/vanilla/src/utils/index.js @@ -1,3 +1,4 @@ export * from "./eventUtils"; export * from "./domUtils"; export * from "./withBatch"; +export * from "./ssrUtils"; diff --git a/packages/vanilla/src/utils/productUtils.js b/packages/vanilla/src/utils/productUtils.js new file mode 100644 index 00000000..d9f24a5a --- /dev/null +++ b/packages/vanilla/src/utils/productUtils.js @@ -0,0 +1,50 @@ +export function filterProducts(products, query) { + let filtered = [...products]; + + // 검색어 필터링 + if (query.search) { + const searchTerm = query.search.toLowerCase(); + filtered = filtered.filter( + (item) => item.title.toLowerCase().includes(searchTerm) || item.brand.toLowerCase().includes(searchTerm), + ); + } + + // 카테고리 필터링 + if (query.category1) { + filtered = filtered.filter((item) => item.category1 === query.category1); + } + if (query.category2) { + filtered = filtered.filter((item) => item.category2 === query.category2); + } + + // 정렬 + if (query.sort) { + switch (query.sort) { + case "price_asc": + filtered.sort((a, b) => parseInt(a.lprice) - parseInt(b.lprice)); + break; + case "price_desc": + filtered.sort((a, b) => parseInt(b.lprice) - parseInt(a.lprice)); + break; + case "name_asc": + filtered.sort((a, b) => a.title.localeCompare(b.title, "ko")); + break; + case "name_desc": + filtered.sort((a, b) => b.title.localeCompare(a.title, "ko")); + break; + default: + // 기본은 가격 낮은 순 + filtered.sort((a, b) => parseInt(a.lprice) - parseInt(b.lprice)); + } + } + + return filtered; +} + +export function loadingProudcts(products) { + const byPriceAsc = (a, b) => Number(a.lprice || 0) - Number(b.lprice || 0); + // 정렬 적용 (기본: price_asc) + let sorted = [...products]; + + return sorted.sort(byPriceAsc); +} diff --git a/packages/vanilla/src/utils/ssrUtils.js b/packages/vanilla/src/utils/ssrUtils.js new file mode 100644 index 00000000..b061e21e --- /dev/null +++ b/packages/vanilla/src/utils/ssrUtils.js @@ -0,0 +1,66 @@ +/** + * SSR 환경 체크 및 브라우저 전용 함수 래퍼 + */ + +export const isServer = typeof window === "undefined"; +export const isBrowser = !isServer; + +/** + * 브라우저에서만 실행되는 함수 래퍼 + */ +export const clientOnly = (fn, fallback = () => {}) => { + return (...args) => { + if (isBrowser) { + return fn(...args); + } + return fallback(...args); + }; +}; + +/** + * 서버에서만 실행되는 함수 래퍼 + */ +export const serverOnly = (fn, fallback = () => {}) => { + return (...args) => { + if (isServer) { + return fn(...args); + } + return fallback(...args); + }; +}; + +/** + * 안전한 localStorage 래퍼 + */ +export const safeLocalStorage = { + getItem: clientOnly( + (key) => window.localStorage.getItem(key), + () => null, + ), + setItem: clientOnly( + (key, value) => window.localStorage.setItem(key, value), + () => {}, + ), + removeItem: clientOnly( + (key) => window.localStorage.removeItem(key), + () => {}, + ), +}; + +/** + * 안전한 DOM 접근 래퍼 + */ +export const safeDocument = { + getElementById: clientOnly( + (id) => document.getElementById(id), + () => null, + ), + querySelector: clientOnly( + (selector) => document.querySelector(selector), + () => null, + ), + addEventListener: clientOnly( + (event, handler) => document.addEventListener(event, handler), + () => {}, + ), +}; diff --git a/packages/vanilla/static-site-generate.js b/packages/vanilla/static-site-generate.js index c479f112..7342de80 100644 --- a/packages/vanilla/static-site-generate.js +++ b/packages/vanilla/static-site-generate.js @@ -1,20 +1,165 @@ import fs from "fs"; -const render = () => { - return `
안녕하세요
`; -}; +// items.json 데이터 로드 +async function loadItemsData() { + try { + const itemsModule = await import("./src/mocks/items.json", { with: { type: "json" } }); + return itemsModule.default; + } catch (error) { + console.error("Failed to load items.json:", error); + return []; + } +} + +// 카테고리 추출 +function getUniqueCategories(itemsList) { + const categories = {}; + itemsList.forEach((item) => { + const cat1 = item.category1; + const cat2 = item.category2; + if (!categories[cat1]) categories[cat1] = {}; + if (cat2 && !categories[cat1][cat2]) categories[cat1][cat2] = {}; + }); + return categories; +} + +// 상품 필터링 및 정렬 +function filterAndSortProducts(products) { + return [...products].sort((a, b) => parseInt(a.lprice) - parseInt(b.lprice)); +} + +// 관련 상품 찾기 +function getRelatedProducts(items, product, limit = 20) { + if (!product.category2) return []; + + return items + .filter((item) => item.category2 === product.category2 && item.productId !== product.productId) + .slice(0, limit); +} + +// 서버사이드 렌더링 함수들 import +async function getServerRenderFunctions() { + try { + const ssrModulePath = `./dist/vanilla-ssr/main-server.js`; + const { render } = await import(ssrModulePath); + return { render }; + } catch (error) { + console.error("Failed to import server render functions:", error); + return { render: null }; + } +} async function generateStaticSite() { + console.log("🚀 Starting SSG generation..."); + + // 데이터 로드 + const items = await loadItemsData(); + const categories = getUniqueCategories(items); + const sortedProducts = filterAndSortProducts(items); + + console.log(`📦 Loaded ${items.length} products`); + + // 서버 렌더링 함수 가져오기 + const { render } = await getServerRenderFunctions(); + + if (!render) { + throw new Error("Server render function not available"); + } + // HTML 템플릿 읽기 const template = fs.readFileSync("../../dist/vanilla/index.html", "utf-8"); - // 어플리케이션 렌더링하기 - const appHtml = render(); + // 1. 홈페이지 생성 + console.log("🏠 Generating homepage..."); + const homeInitialData = { + products: sortedProducts.slice(0, 20), // 첫 20개 상품 + categories, + totalCount: items.length, + loading: false, + error: null, + currentProduct: null, + relatedProducts: [], + }; + + const homeRendered = await render("/", {}, homeInitialData, { doSSR: true }); + const homeHtml = template + .replace(``, homeRendered.head || "") + .replace(``, homeRendered.html || "") + .replace( + ``, + ``, + ); + + fs.writeFileSync("../../dist/vanilla/index.html", homeHtml); + console.log("✅ Homepage generated"); + + // 2. 상품 상세 페이지들 생성 + console.log("📋 Generating product detail pages..."); + + // product 디렉토리 생성 + const productDir = "../../dist/vanilla/product"; + if (!fs.existsSync(productDir)) { + fs.mkdirSync(productDir, { recursive: true }); + } + + // 각 상품에 대해 상세 페이지 생성 + for (const product of items) { + const productId = product.productId; + const productDir = `../../dist/vanilla/product/${productId}`; + + // 상품별 디렉토리 생성 + if (!fs.existsSync(productDir)) { + fs.mkdirSync(productDir, { recursive: true }); + } + + // 상품 상세 데이터 준비 + const enhancedProduct = { + ...product, + description: `${product.title}에 대한 상세 설명입니다. ${product.brand} 브랜드의 우수한 품질을 자랑하는 상품으로, 고객 만족도가 높은 제품입니다.`, + rating: Math.floor(Math.random() * 2) + 4, + reviewCount: Math.floor(Math.random() * 1000) + 50, + stock: Math.floor(Math.random() * 100) + 10, + images: [product.image, product.image.replace(".jpg", "_2.jpg"), product.image.replace(".jpg", "_3.jpg")], + }; + + const relatedProducts = getRelatedProducts(items, product); + + const productInitialData = { + products: [], + categories, + totalCount: 0, + loading: false, + error: null, + currentProduct: enhancedProduct, + relatedProducts, + }; + + const productRendered = await render(`/product/${productId}/`, {}, productInitialData, { doSSR: true }); + const productHtml = template + .replace(``, productRendered.head || "") + .replace(``, productRendered.html || "") + .replace( + ``, + ``, + ); + + fs.writeFileSync(`${productDir}/index.html`, productHtml); + + // 진행률 출력 + if (items.indexOf(product) % 50 === 0) { + console.log(`📋 Generated ${items.indexOf(product) + 1}/${items.length} product pages...`); + } + } - // 결과 HTML 생성하기 - const result = template.replace("", appHtml); - fs.writeFileSync("../../dist/vanilla/index.html", result); + console.log(`✅ Generated ${items.length} product detail pages`); + console.log("🎉 SSG generation completed!"); } // 실행 -generateStaticSite(); +generateStaticSite().catch(console.error); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 766adcbc..4ed60ae7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,7 +101,7 @@ importers: version: 6.8.0 '@testing-library/react': specifier: latest - version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.1.7(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@testing-library/user-event': specifier: latest version: 14.6.1(@testing-library/dom@10.4.1) @@ -110,10 +110,10 @@ importers: version: 24.0.13 '@types/react': specifier: latest - version: 19.1.11 + version: 19.1.12 '@types/react-dom': specifier: latest - version: 19.1.7(@types/react@19.1.11) + version: 19.1.9(@types/react@19.1.12) '@types/use-sync-external-store': specifier: latest version: 1.5.0 @@ -177,16 +177,16 @@ importers: version: 24.0.13 '@types/react': specifier: latest - version: 19.1.11 + version: 19.1.12 '@types/react-dom': specifier: latest - version: 19.1.7(@types/react@19.1.11) + version: 19.1.9(@types/react@19.1.12) '@types/use-sync-external-store': specifier: latest version: 1.5.0 '@vitejs/plugin-react': specifier: latest - version: 5.0.1(rolldown-vite@7.1.5(@types/node@24.0.13)(yaml@2.8.0)) + version: 5.0.2(rolldown-vite@7.1.5(@types/node@24.0.13)(yaml@2.8.0)) compression: specifier: ^1.7.5 version: 1.8.1 @@ -1103,13 +1103,13 @@ packages: '@types/node@24.0.13': resolution: {integrity: sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ==} - '@types/react-dom@19.1.7': - resolution: {integrity: sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==} + '@types/react-dom@19.1.9': + resolution: {integrity: sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==} peerDependencies: '@types/react': ^19.0.0 - '@types/react@19.1.11': - resolution: {integrity: sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ==} + '@types/react@19.1.12': + resolution: {integrity: sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==} '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} @@ -1191,8 +1191,8 @@ packages: peerDependencies: vite: ^4 || ^5 || ^6 || ^7 - '@vitejs/plugin-react@5.0.1': - resolution: {integrity: sha512-DE4UNaBXwtVoDJ0ccBdLVjFTWL70NRuWNCxEieTI3lrq9ORB9aOCQEKstwDXBl87NvFdbqh/p7eINGyj0BthJA==} + '@vitejs/plugin-react@5.0.2': + resolution: {integrity: sha512-tmyFgixPZCx2+e6VO9TNITWcCQl8+Nl/E8YbAyPVv85QCc7/A3JrdfG2A8gIzvVhWuzMOVrFW1aReaNxrI6tbw==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 @@ -3701,15 +3701,15 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.1.7(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + '@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: '@babel/runtime': 7.27.6 '@testing-library/dom': 10.4.1 react: 19.1.1 react-dom: 19.1.1(react@19.1.1) optionalDependencies: - '@types/react': 19.1.11 - '@types/react-dom': 19.1.7(@types/react@19.1.11) + '@types/react': 19.1.12 + '@types/react-dom': 19.1.9(@types/react@19.1.12) '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: @@ -3759,11 +3759,11 @@ snapshots: dependencies: undici-types: 7.8.0 - '@types/react-dom@19.1.7(@types/react@19.1.11)': + '@types/react-dom@19.1.9(@types/react@19.1.12)': dependencies: - '@types/react': 19.1.11 + '@types/react': 19.1.12 - '@types/react@19.1.11': + '@types/react@19.1.12': dependencies: csstype: 3.1.3 @@ -3878,12 +3878,12 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' - '@vitejs/plugin-react@5.0.1(rolldown-vite@7.1.5(@types/node@24.0.13)(yaml@2.8.0))': + '@vitejs/plugin-react@5.0.2(rolldown-vite@7.1.5(@types/node@24.0.13)(yaml@2.8.0))': dependencies: '@babel/core': 7.28.3 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.3) '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.3) - '@rolldown/pluginutils': 1.0.0-beta.32 + '@rolldown/pluginutils': 1.0.0-beta.34 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 vite: rolldown-vite@7.1.5(@types/node@24.0.13)(yaml@2.8.0)