diff --git a/.husky/commit-msg b/.husky/commit-msg index 67ebbdb..2c9c133 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -3,7 +3,7 @@ pnpm commitlint --edit "$1" || { echo echo "❌ 커밋 메시지 규칙 위반!" - echo " 형식: CDP-숫자 type이모지(scope): subject" + echo " 형식: CDP-숫자 type이모지 (scope): subject" echo " 예시: CDP-31 fix🐛(ci): CI error resolution" echo " 허용: feat✨, fix🐛, refactor🔨, style🎨, chore⚙️, docs📝, test🧪" exit 1 diff --git a/commitlint.config.cjs b/commitlint.config.cjs index de4c809..bc88f5a 100644 --- a/commitlint.config.cjs +++ b/commitlint.config.cjs @@ -1,11 +1,10 @@ -/** commitlint.config.cjs */ module.exports = { extends: ["@commitlint/config-conventional"], parserPreset: { parserOpts: { - // CDP-123 chore⚙️(scope): subject + // CDP-123 chore⚙️ (scope): subject headerPattern: - /^(CDP-\d+)\s(feat✨|fix🐛|refactor🔨|style🎨|chore⚙️|docs📝|test🧪)\(([^)]+)\):\s(.+)$/, + /^(CDP-\d+)\s(feat✨|fix🐛|refactor🔨|style🎨|chore⚙️|docs📝|test🧪)\s\(([^)]+)\):\s(.+)$/, headerCorrespondence: ["ticket", "type", "scope", "subject"], }, }, diff --git a/next-sitemap.config.js b/next-sitemap.config.js new file mode 100644 index 0000000..adc4705 --- /dev/null +++ b/next-sitemap.config.js @@ -0,0 +1,48 @@ +const RAW_SITE_URL = process.env.SITE_URL ?? "https://myplanmate.vercel.app"; +const siteUrl = RAW_SITE_URL.replace(/\/+$/, ""); // 끝 슬래시 제거 + +// "/" 이외 경로의 끝 슬래시 제거 +const strip = (p) => (p !== "/" && p.endsWith("/") ? p.slice(0, -1) : p); + +const config = { + siteUrl, + generateRobotsTxt: true, + outDir: "public", + sitemapSize: 5000, + + // 기존 exclude 그대로 유지 + exclude: ["/api/*", "/admin/*", "/debug", "/lab/*"], + + // 이 canonical과 1:1로 동일하도록 정규화 + transform: async (cfg, path) => { + const loc = strip(path); + + // 우선순위 규칙(네 로직 보존) + const priority = loc === "/" ? 1.0 : loc.startsWith("/blog") ? 0.8 : (cfg.priority ?? 0.7); + + return { + loc, // ✅ canonical과 동일한 문자열 + changefreq: "daily", + priority, + lastmod: new Date().toISOString(), + + // 언어별 페이지가 있다면 경로 단위로 alternateRefs 매핑 + // 예) /todos → /ko/todos, /en/todos + alternateRefs: [ + { href: `${siteUrl}/ko${loc === "/" ? "" : loc}`, hreflang: "ko" }, + { href: `${siteUrl}/en${loc === "/" ? "" : loc}`, hreflang: "en" }, + ], + }; + }, + + // robots.txt (네 로직 보존) + robotsTxtOptions: { + policies: [ + { userAgent: "*", allow: "/" }, + { userAgent: "*", disallow: ["/api/", "/admin/", "/debug", "/lab/"] }, + ], + additionalSitemaps: [`${siteUrl}/server-sitemap.xml`], // 동적 sitemap + }, +}; + +export default config; diff --git a/next.config.ts b/next.config.ts index 988fbf9..76df240 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,5 +1,13 @@ +// next.config.ts import type { NextConfig } from "next"; +/** 대표 도메인 (없으면 Vercel 프리뷰 기본값) */ +const RAW_SITE_URL = process.env.SITE_URL ?? "https://myplanmate.vercel.app"; +const url = new URL(RAW_SITE_URL); +const NON_WWW_HOST = url.hostname.replace(/^www\./, ""); +const WWW_HOST = url.hostname.startsWith("www.") ? url.hostname : `www.${NON_WWW_HOST}`; +const DEST_ORIGIN = `${url.protocol}//${NON_WWW_HOST}`; + const nextConfig: NextConfig = { reactStrictMode: true, poweredByHeader: false, // x-powered-by: Next.js 헤더 제거 @@ -29,14 +37,8 @@ const nextConfig: NextConfig = { { source: "/:path*", headers: [ - { - key: "X-Frame-Options", - value: "DENY", // 클릭재킹 방지 - }, - { - key: "X-Content-Type-Options", - value: "nosniff", // MIME 타입 스니핑 방지 - }, + { key: "X-Frame-Options", value: "DENY" }, // 클릭재킹 방지 + { key: "X-Content-Type-Options", value: "nosniff" }, // MIME 타입 스니핑 방지 { key: "Referrer-Policy", value: "strict-origin-when-cross-origin", // 안전한 referrer 전송 @@ -49,6 +51,22 @@ const nextConfig: NextConfig = { }, ]; }, + + // ✅ 3) 중복 주소 정규화 (추가) + async redirects() { + return [ + // A) 트레일링 슬래시 제거: /path/ → /path + { source: "/:path*/", destination: "/:path*", permanent: true }, + + // B) www → non-www: https://www./* → https:///* + { + source: "/:path*", + has: [{ type: "host", value: WWW_HOST }], + destination: `${DEST_ORIGIN}/:path*`, + permanent: true, + }, + ]; + }, }; export default nextConfig; diff --git a/package.json b/package.json index 903111e..3df975e 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "dev": "next dev", "build": "next build", "start": "next start", + "postbuild": "next-sitemap", + "sitemap": "next-sitemap", "lint": "eslint . --ext .js,.jsx,.ts,.tsx --max-warnings=0", "lint:fix": "pnpm lint --fix", "format": "prettier --write .", @@ -55,6 +57,7 @@ "eslint-plugin-prettier": "^5.5.4", "husky": "^9.1.7", "lint-staged": "^16.2.0", + "next-sitemap": "^4.2.3", "prettier": "^3.6.2", "prettier-plugin-tailwindcss": "^0.6.14", "tailwindcss": "^4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6675554..148530e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: lint-staged: specifier: ^16.2.0 version: 16.2.0 + next-sitemap: + specifier: ^4.2.3 + version: 4.2.3(next@15.5.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) prettier: specifier: ^3.6.2 version: 3.6.2 @@ -247,6 +250,12 @@ packages: } engines: { node: ">=v18" } + "@corex/deepmerge@4.0.43": + resolution: + { + integrity: sha512-N8uEMrMPL0cu/bdboEWpQYb/0i2K5Qn8eCsxzOmxSggJbbQte7ljMRoXm917AbntqTGOzdTu+vP3KOOzoC70HQ==, + } + "@emnapi/core@1.5.0": resolution: { @@ -596,6 +605,12 @@ packages: integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==, } + "@next/env@13.5.11": + resolution: + { + integrity: sha512-fbb2C7HChgM7CemdCY+y3N1n8pcTKdqtQLbC7/EQtPdLvlMUT9JX/dBYl8MMZAtYG4uVMyPFHXckb68q/NRwqg==, + } + "@next/env@15.5.3": resolution: { @@ -3144,6 +3159,16 @@ packages: react: ">=16.0.0" react-dom: ">=16.0.0" + next-sitemap@4.2.3: + resolution: + { + integrity: sha512-vjdCxeDuWDzldhCnyFCQipw5bfpl4HmZA7uoo3GAaYGjGgfL4Cxb1CiztPuWGmS+auYs7/8OekRS8C2cjdAsjQ==, + } + engines: { node: ">=14.18" } + hasBin: true + peerDependencies: + next: "*" + next@15.5.3: resolution: { @@ -4320,6 +4345,8 @@ snapshots: "@types/conventional-commits-parser": 5.0.1 chalk: 5.6.2 + "@corex/deepmerge@4.0.43": {} + "@emnapi/core@1.5.0": dependencies: "@emnapi/wasi-threads": 1.1.0 @@ -4509,6 +4536,8 @@ snapshots: "@tybys/wasm-util": 0.10.1 optional: true + "@next/env@13.5.11": {} + "@next/env@15.5.3": {} "@next/eslint-plugin-next@15.5.3": @@ -6101,6 +6130,14 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) + next-sitemap@4.2.3(next@15.5.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)): + dependencies: + "@corex/deepmerge": 4.0.43 + "@next/env": 13.5.11 + fast-glob: 3.3.3 + minimist: 1.2.8 + next: 15.5.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next@15.5.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: "@next/env": 15.5.3 diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..175c578 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,17 @@ +# * +User-agent: * +Allow: / + +# * +User-agent: * +Disallow: /api/ +Disallow: /admin/ +Disallow: /debug +Disallow: /lab/ + +# Host +Host: https://myplanmate.vercel.app + +# Sitemaps +Sitemap: https://myplanmate.vercel.app/sitemap.xml +Sitemap: https://myplanmate.vercel.app/server-sitemap.xml diff --git a/public/sitemap-0.xml b/public/sitemap-0.xml new file mode 100644 index 0000000..1f39ac9 --- /dev/null +++ b/public/sitemap-0.xml @@ -0,0 +1,4 @@ + + +https://myplanmate.vercel.app2025-10-21T17:20:15.421Zdaily1 + \ No newline at end of file diff --git a/public/sitemap.xml b/public/sitemap.xml new file mode 100644 index 0000000..21081b4 --- /dev/null +++ b/public/sitemap.xml @@ -0,0 +1,5 @@ + + +https://myplanmate.vercel.app/sitemap-0.xml +https://myplanmate.vercel.app/server-sitemap.xml + \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 3c8ba87..cac59a3 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -9,12 +9,12 @@ import { LOCALE, OG_DEFAULT_IMAGE, SITE_NAME, - SITE_URL, + SITE_URL, // ✅ constants에서 SITE_URL 사용 TITLE_TEMPLATE, } from "@/seo/constants"; export const metadata: Metadata = { - // 절대 URL 기준점 (canonical/OG 절대경로 변환에 사용) + // ✅ 절대 URL 기준점 (canonical / OG 절대경로 변환용) metadataBase: new URL(SITE_URL), // 전역 타이틀 규칙 @@ -48,6 +48,15 @@ export const metadata: Metadata = { twitter: { card: "summary_large_image", }, + + // canonical 및 언어별 hreflang + alternates: { + canonical: "/", // => https://myplanmate.vercel.app/ + languages: { + ko: "/ko", // => https://myplanmate.vercel.app/ko + en: "/en", // => https://myplanmate.vercel.app/en + }, + }, }; export default function RootLayout({ children }: { children: React.ReactNode }) { @@ -60,15 +69,6 @@ export default function RootLayout({ children }: { children: React.ReactNode }) - 모든 하위 컴포넌트가 동일한 client & cache 공유 - useQuery, useMutation 훅이 어디서든 정상 동작 */} - {/* - ✅ 추후 확장 예시: - - - {children} - - - */} - {children}