diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2752430 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main ] + +jobs: + quality-check: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run Biome checks + run: pnpm biome:check + + - name: Type check + run: pnpm tsc --noEmit + + - name: Build + run: pnpm build + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: build-output + path: out/ + retention-days: 1 \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..2a37eeb --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,59 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: [ main ] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Setup Pages + uses: actions/configure-pages@v4 + with: + static_site_generator: next + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build with Next.js + run: pnpm build + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: ./out + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index e5f66db..af3b977 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,7 @@ { "biome.requireConfiguration": true, "editor.codeActionsOnSave": { - "quickfix.biome": "explicit", + "source.fixAll.biome": "explicit", "source.organizeImports.biome": "explicit" }, "editor.defaultFormatter": "biomejs.biome", diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..dc4eaa2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,58 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +- `pnpm dev` - Start development server on localhost:3000 +- `pnpm build` - Build the application for production (configured for static export) +- `pnpm start` - Start production server +- `pnpm biome:check` - Run Biome formatter and linter with auto-fix +- `pnpm biome:staged` - Run Biome on staged files only + +## Code Quality + +This project uses Biome for formatting and linting. Always run `pnpm biome:check` before committing changes. The project has a pre-commit hook (lefthook) that automatically runs Biome on staged files. + +## Architecture Overview + +### Next.js Blog with MDX + +This is a Next.js 15 blog application configured for static export with MDX support for content authoring. + +**Key Technologies:** +- Next.js 15 with App Router +- MDX for blog content with rehype-pretty-code syntax highlighting +- Tailwind CSS for styling +- TypeScript +- Biome for code formatting/linting + +### Directory Structure + +- `src/app/` - Next.js App Router pages and layouts +- `src/contents/` - MDX blog posts (*.mdx files) +- `src/api/posts.ts` - Blog post data fetching utilities +- `src/app/blog/[slug]/` - Dynamic blog post pages + +### Content Management + +Blog posts are stored as MDX files in `src/contents/`. The `src/api/posts.ts` module handles: +- Reading MDX files from the contents directory +- Parsing frontmatter with gray-matter +- Generating static routes for blog posts + +### Static Generation + +The app is configured for static export (`output: 'export'` in next.config.ts) and uses: +- `generateStaticParams()` for blog post routes +- Dynamic imports for MDX components in blog pages + +### Styling + +- Uses Tailwind CSS with custom configuration +- Noto Sans KR font for Korean language support +- Prose styling for blog content rendering + +### Build Output + +Static files are generated in the `out/` directory during build. \ No newline at end of file diff --git a/biome.json b/biome.json index af65832..575be15 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.0.6/schema.json", + "$schema": "https://biomejs.dev/schemas/2.1.1/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/components.json b/components.json index ebb3db6..7754090 100644 --- a/components.json +++ b/components.json @@ -5,17 +5,17 @@ "tsx": true, "tailwind": { "config": "", - "css": "src/app/globals.css", + "css": "src/app/_styles/globals.css", "baseColor": "stone", "cssVariables": true, "prefix": "" }, "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" + "components": "@/app/_components", + "utils": "@/app/_lib/utils", + "ui": "@/app/_components/ui", + "lib": "@/app/_lib", + "hooks": "@/app/_hooks" }, "iconLibrary": "lucide" } diff --git a/next.config.ts b/next.config.ts index babf516..abe7c71 100644 --- a/next.config.ts +++ b/next.config.ts @@ -15,7 +15,7 @@ const nextConfig: NextConfig = { } const withMDX = createMDX({ - extension: /\.(md|mdx)$/, + extension: /\.(mdx)$/, options: { rehypePlugins: [ [ diff --git a/package.json b/package.json index 07cf05a..28702e1 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev --turbopack", + "dev": "next dev", "build": "next build", "start": "next start", "biome:check": "biome check --write --verbose", @@ -16,6 +16,7 @@ "@types/mdx": "^2.0.13", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "gray-matter": "^4.0.3", "lucide-react": "^0.525.0", "next": "15.3.5", "react": "^19.1.0", @@ -26,6 +27,7 @@ "devDependencies": { "@biomejs/biome": "^2.1.1", "@tailwindcss/postcss": "^4.1.11", + "@tailwindcss/typography": "^0.5.16", "@types/node": "^20.19.7", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a5a322..3ce4d73 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + gray-matter: + specifier: ^4.0.3 + version: 4.0.3 lucide-react: specifier: ^0.525.0 version: 0.525.0(react@19.1.0) @@ -51,6 +54,9 @@ importers: '@tailwindcss/postcss': specifier: ^4.1.11 version: 4.1.11 + '@tailwindcss/typography': + specifier: ^0.5.16 + version: 0.5.16(tailwindcss@4.1.11) '@types/node': specifier: ^20.19.7 version: 20.19.7 @@ -472,6 +478,11 @@ packages: '@tailwindcss/postcss@4.1.11': resolution: {integrity: sha512-q/EAIIpF6WpLhKEuQSEVMZNMIY8KhWoAemZ9eylNAih9jxMGAYPPWBn3I9QL/2jZ+e7OEz/tZkX5HwbBR4HohA==} + '@tailwindcss/typography@0.5.16': + resolution: {integrity: sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -523,6 +534,9 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + astring@1.9.0: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true @@ -586,6 +600,11 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} @@ -626,6 +645,11 @@ packages: esast-util-from-js@2.0.1: resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + estree-util-attach-comments@3.0.0: resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==} @@ -647,12 +671,20 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + extend-shallow@2.0.1: + resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} + engines: {node: '>=0.10.0'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + gray-matter@4.0.3: + resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} + engines: {node: '>=6.0'} + hast-util-from-html@2.0.3: resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} @@ -698,6 +730,10 @@ packages: is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-extendable@0.1.1: + resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} + engines: {node: '>=0.10.0'} + is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} @@ -709,6 +745,14 @@ packages: resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} hasBin: true + js-yaml@3.14.1: + resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + hasBin: true + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + lefthook-darwin-arm64@1.12.2: resolution: {integrity: sha512-fTxeI9tEskrHjc3QyEO+AG7impBXY2Ed8V5aiRc3fw9POfYtVh9b5jRx90fjk2+ld5hf+Z1DsyyLq/vOHDFskQ==} cpu: [arm64] @@ -827,6 +871,15 @@ packages: resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==} engines: {node: '>= 12.0.0'} + lodash.castarray@4.4.0: + resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -1013,6 +1066,10 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + postcss-selector-parser@6.0.10: + resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} + engines: {node: '>=4'} + postcss@8.4.31: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} @@ -1078,6 +1135,10 @@ packages: scheduler@0.26.0: resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} + section-matter@1.0.0: + resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} + engines: {node: '>=4'} + semver@7.7.2: resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} engines: {node: '>=10'} @@ -1104,6 +1165,9 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + streamsearch@1.1.0: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} @@ -1111,6 +1175,10 @@ packages: stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-bom-string@1.0.0: + resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} + engines: {node: '>=0.10.0'} + style-to-js@1.1.17: resolution: {integrity: sha512-xQcBGDxJb6jjFCTzvQtfiPn6YvvP2O8U1MDIPNfJQlWMYfktPy+iGsHE7cssjs7y84d9fQaK4UF3RIJaAHSoYA==} @@ -1185,6 +1253,9 @@ packages: unist-util-visit@5.0.0: resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} @@ -1545,6 +1616,14 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.11 + '@tailwindcss/typography@0.5.16(tailwindcss@4.1.11)': + dependencies: + lodash.castarray: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.merge: 4.6.2 + postcss-selector-parser: 6.0.10 + tailwindcss: 4.1.11 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -1591,6 +1670,10 @@ snapshots: acorn@8.15.0: {} + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + astring@1.9.0: {} bail@2.0.2: {} @@ -1645,6 +1728,8 @@ snapshots: comma-separated-tokens@2.0.3: {} + cssesc@3.0.0: {} + csstype@3.1.3: {} debug@4.4.1: @@ -1684,6 +1769,8 @@ snapshots: esast-util-from-estree: 2.0.0 vfile-message: 4.0.2 + esprima@4.0.1: {} + estree-util-attach-comments@3.0.0: dependencies: '@types/estree': 1.0.8 @@ -1717,10 +1804,21 @@ snapshots: dependencies: '@types/estree': 1.0.8 + extend-shallow@2.0.1: + dependencies: + is-extendable: 0.1.1 + extend@3.0.2: {} graceful-fs@4.2.11: {} + gray-matter@4.0.3: + dependencies: + js-yaml: 3.14.1 + kind-of: 6.0.3 + section-matter: 1.0.0 + strip-bom-string: 1.0.0 + hast-util-from-html@2.0.3: dependencies: '@types/hast': 3.0.4 @@ -1832,12 +1930,21 @@ snapshots: is-decimal@2.0.1: {} + is-extendable@0.1.1: {} + is-hexadecimal@2.0.1: {} is-plain-obj@4.1.0: {} jiti@2.4.2: {} + js-yaml@3.14.1: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + kind-of@6.0.3: {} + lefthook-darwin-arm64@1.12.2: optional: true @@ -1926,6 +2033,12 @@ snapshots: lightningcss-win32-arm64-msvc: 1.30.1 lightningcss-win32-x64-msvc: 1.30.1 + lodash.castarray@4.4.0: {} + + lodash.isplainobject@4.0.6: {} + + lodash.merge@4.6.2: {} + longest-streak@3.1.0: {} lucide-react@0.525.0(react@19.1.0): @@ -2306,6 +2419,11 @@ snapshots: picocolors@1.1.1: {} + postcss-selector-parser@6.0.10: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + postcss@8.4.31: dependencies: nanoid: 3.3.11 @@ -2417,6 +2535,11 @@ snapshots: scheduler@0.26.0: {} + section-matter@1.0.0: + dependencies: + extend-shallow: 2.0.1 + kind-of: 6.0.3 + semver@7.7.2: optional: true @@ -2472,6 +2595,8 @@ snapshots: space-separated-tokens@2.0.2: {} + sprintf-js@1.0.3: {} + streamsearch@1.1.0: {} stringify-entities@4.0.4: @@ -2479,6 +2604,8 @@ snapshots: character-entities-html4: 2.1.0 character-entities-legacy: 3.0.0 + strip-bom-string@1.0.0: {} + style-to-js@1.1.17: dependencies: style-to-object: 1.0.9 @@ -2556,6 +2683,8 @@ snapshots: unist-util-is: 6.0.0 unist-util-visit-parents: 6.0.1 + util-deprecate@1.0.2: {} + vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3 diff --git a/src/api/posts.ts b/src/api/posts.ts new file mode 100644 index 0000000..37dfc4b --- /dev/null +++ b/src/api/posts.ts @@ -0,0 +1,34 @@ +import { readdirSync } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import matter, { type GrayMatterFile } from 'gray-matter' + +const postsDirectory = join(process.cwd(), 'src', 'contents') + +export function getPostSlugs() { + return readdirSync(postsDirectory).filter((file) => file.endsWith('.mdx')) +} + +interface Post extends GrayMatterFile { + date: string + title: string + slug: string + content: string +} + +export async function getPostBySlug(slug: string) { + const realSlug = slug.replace(/\.mdx$/, '') + const fullPath = join(postsDirectory, `${realSlug}.mdx`) + const fileContents = await readFile(fullPath, 'utf-8') + + return Object.assign(matter(fileContents), { + slug: realSlug, + }) as Post +} + +export async function getAllPosts() { + const slugs = getPostSlugs() + const postPromises = slugs.map((slug) => getPostBySlug(slug)) + const posts = await Promise.all(postPromises) + return posts.toSorted((post1, post2) => (post1.date > post2.date ? -1 : 1)) +} diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx new file mode 100644 index 0000000..8f697c4 --- /dev/null +++ b/src/app/about/page.tsx @@ -0,0 +1,3 @@ +export default function AboutPage() { + return
AboutPage
+} diff --git a/src/app/blog/[slug]/page.tsx b/src/app/blog/[slug]/page.tsx new file mode 100644 index 0000000..c602753 --- /dev/null +++ b/src/app/blog/[slug]/page.tsx @@ -0,0 +1,38 @@ +import { notFound } from 'next/navigation' + +import { getAllPosts } from '@/api/posts' + +export async function generateStaticParams() { + const posts = await getAllPosts() + + const params = posts.map((post) => ({ + slug: encodeURIComponent(post.slug), + })) + + return params +} + +export default async function PostPage({ + params, +}: { + params: Promise<{ + slug: string + }> +}) { + const { slug } = await params + + try { + const { default: Post } = await import( + `@/contents/${decodeURIComponent(slug)}.mdx` + ) + + return ( +
+ +
+ ) + } catch (_error) { + console.log(_error) + notFound() + } +} diff --git a/src/app/globals.css b/src/app/globals.css index f127db3..f1d8c73 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,122 +1 @@ @import "tailwindcss"; -@import "tw-animate-css"; - -@custom-variant dark (&:is(.dark *)); - -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); - --color-sidebar-ring: var(--sidebar-ring); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar: var(--sidebar); - --color-chart-5: var(--chart-5); - --color-chart-4: var(--chart-4); - --color-chart-3: var(--chart-3); - --color-chart-2: var(--chart-2); - --color-chart-1: var(--chart-1); - --color-ring: var(--ring); - --color-input: var(--input); - --color-border: var(--border); - --color-destructive: var(--destructive); - --color-accent-foreground: var(--accent-foreground); - --color-accent: var(--accent); - --color-muted-foreground: var(--muted-foreground); - --color-muted: var(--muted); - --color-secondary-foreground: var(--secondary-foreground); - --color-secondary: var(--secondary); - --color-primary-foreground: var(--primary-foreground); - --color-primary: var(--primary); - --color-popover-foreground: var(--popover-foreground); - --color-popover: var(--popover); - --color-card-foreground: var(--card-foreground); - --color-card: var(--card); - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); -} - -:root { - --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.147 0.004 49.25); - --card: oklch(1 0 0); - --card-foreground: oklch(0.147 0.004 49.25); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.147 0.004 49.25); - --primary: oklch(0.216 0.006 56.043); - --primary-foreground: oklch(0.985 0.001 106.423); - --secondary: oklch(0.97 0.001 106.424); - --secondary-foreground: oklch(0.216 0.006 56.043); - --muted: oklch(0.97 0.001 106.424); - --muted-foreground: oklch(0.553 0.013 58.071); - --accent: oklch(0.97 0.001 106.424); - --accent-foreground: oklch(0.216 0.006 56.043); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.923 0.003 48.717); - --input: oklch(0.923 0.003 48.717); - --ring: oklch(0.709 0.01 56.259); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0.001 106.423); - --sidebar-foreground: oklch(0.147 0.004 49.25); - --sidebar-primary: oklch(0.216 0.006 56.043); - --sidebar-primary-foreground: oklch(0.985 0.001 106.423); - --sidebar-accent: oklch(0.97 0.001 106.424); - --sidebar-accent-foreground: oklch(0.216 0.006 56.043); - --sidebar-border: oklch(0.923 0.003 48.717); - --sidebar-ring: oklch(0.709 0.01 56.259); -} - -.dark { - --background: oklch(0.147 0.004 49.25); - --foreground: oklch(0.985 0.001 106.423); - --card: oklch(0.216 0.006 56.043); - --card-foreground: oklch(0.985 0.001 106.423); - --popover: oklch(0.216 0.006 56.043); - --popover-foreground: oklch(0.985 0.001 106.423); - --primary: oklch(0.923 0.003 48.717); - --primary-foreground: oklch(0.216 0.006 56.043); - --secondary: oklch(0.268 0.007 34.298); - --secondary-foreground: oklch(0.985 0.001 106.423); - --muted: oklch(0.268 0.007 34.298); - --muted-foreground: oklch(0.709 0.01 56.259); - --accent: oklch(0.268 0.007 34.298); - --accent-foreground: oklch(0.985 0.001 106.423); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.553 0.013 58.071); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.216 0.006 56.043); - --sidebar-foreground: oklch(0.985 0.001 106.423); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0.001 106.423); - --sidebar-accent: oklch(0.268 0.007 34.298); - --sidebar-accent-foreground: oklch(0.985 0.001 106.423); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.553 0.013 58.071); -} - -@layer base { - * { - @apply border-border outline-ring/50; - } - body { - @apply bg-background text-foreground; - } -} diff --git a/src/app/mdx-test/Comp.tsx b/src/app/mdx-test/Comp.tsx deleted file mode 100644 index 4ac4da4..0000000 --- a/src/app/mdx-test/Comp.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function MyComponent() { - return
MyComponent
-} diff --git a/src/app/mdx-test/page.mdx b/src/app/mdx-test/page.mdx deleted file mode 100644 index 309cc37..0000000 --- a/src/app/mdx-test/page.mdx +++ /dev/null @@ -1,32 +0,0 @@ -import MyComponent from './Comp' - -# Welcome to my MDX page! - -This is some **bold** and _italics_ text. - -This is a list in markdown: - -- One -- Two -- Three - -Checkout my React component: - - - -```tsx -const a = 1 -type b = Record -const c: b = { - a: 1, - b: 2, -} - -function f(a: b) { - return a.a -} - -const e = f(c) - -console.log(e) -``` \ No newline at end of file diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..be8af9f --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation' + +export default function RootNotFound() { + redirect('/') +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 31b5b2f..8699fef 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -4,7 +4,8 @@ export default function Home() { return (
Hello World - MdxTest + About + Blog
) } diff --git a/src/contents/ss.mdx b/src/contents/ss.mdx new file mode 100644 index 0000000..31bcd46 --- /dev/null +++ b/src/contents/ss.mdx @@ -0,0 +1,7 @@ +# ss + +asdasd + +```tsx +const a = 1 +``` \ No newline at end of file diff --git "a/src/contents/\343\205\207\343\205\207.mdx" "b/src/contents/\343\205\207\343\205\207.mdx" new file mode 100644 index 0000000..15d413e --- /dev/null +++ "b/src/contents/\343\205\207\343\205\207.mdx" @@ -0,0 +1,162 @@ +# App Router에서 getStaticParams와 404 페이지 처리하기 + +Next.js 13부터 도입된 App Router에서는 동적 라우팅과 정적 생성을 위한 새로운 방식이 도입되었습니다. 이번 글에서는 `generateStaticParams` 함수와 404 페이지 처리에 대해 알아보겠습니다. + +## generateStaticParams란? + +App Router에서 `generateStaticParams`는 Pages Router의 `getStaticPaths`를 대체하는 함수입니다. 빌드 시점에 동적 라우트의 매개변수들을 미리 생성하여 정적 페이지들을 만들어줍니다. + +### 기본 사용법 + +```typescript +// app/blog/[slug]/page.tsx +export async function generateStaticParams() { + const posts = await fetch('https://api.example.com/posts').then((res) => res.json()) + + return posts.map((post) => ({ + slug: post.slug, + })) +} + +export default function Page({ params }: { params: { slug: string } }) { + return
Post: {params.slug}
+} +``` + +## 404 페이지 처리 + +App Router에서 404 페이지를 처리하는 방법은 여러 가지가 있습니다. + +### 1. not-found.tsx 파일 사용 + +```typescript +// app/not-found.tsx +export default function NotFound() { + return ( +
+

페이지를 찾을 수 없습니다

+

요청하신 페이지를 찾을 수 없습니다.

+
+ ) +} +``` + +### 2. 동적 라우트에서 notFound() 함수 사용 + +```typescript +// app/blog/[slug]/page.tsx +import { notFound } from 'next/navigation' + +export async function generateStaticParams() { + const posts = await fetch('https://api.example.com/posts').then((res) => res.json()) + + return posts.map((post) => ({ + slug: post.slug, + })) +} + +export default async function Page({ params }: { params: { slug: string } }) { + const post = await fetch(`https://api.example.com/posts/${params.slug}`) + .then((res) => res.json()) + .catch(() => null) + + if (!post) { + notFound() + } + + return ( +
+

{post.title}

+

{post.content}

+
+ ) +} +``` + +## generateStaticParams와 404 페이지 조합하기 + +`generateStaticParams`에서 정의하지 않은 매개변수로 접근할 때의 동작을 제어할 수 있습니다. + +### dynamicParams 옵션 + +```typescript +// app/blog/[slug]/page.tsx +export const dynamicParams = false // 기본값은 true + +export async function generateStaticParams() { + return [ + { slug: 'post-1' }, + { slug: 'post-2' }, + { slug: 'post-3' }, + ] +} +``` + +- `dynamicParams = true` (기본값): 정의되지 않은 매개변수도 동적으로 생성 +- `dynamicParams = false`: 정의되지 않은 매개변수는 404 페이지 반환 + +### 실제 사용 예시 + +```typescript +// app/blog/[slug]/page.tsx +import { notFound } from 'next/navigation' + +export const dynamicParams = false + +export async function generateStaticParams() { + try { + const posts = await fetch('https://api.example.com/posts', { + next: { revalidate: 3600 } // 1시간마다 재검증 + }).then((res) => res.json()) + + return posts.map((post) => ({ + slug: post.slug, + })) + } catch (error) { + console.error('Failed to fetch posts:', error) + return [] + } +} + +async function getPost(slug: string) { + try { + const post = await fetch(`https://api.example.com/posts/${slug}`, { + next: { revalidate: 3600 } + }).then((res) => { + if (!res.ok) throw new Error('Post not found') + return res.json() + }) + + return post + } catch (error) { + return null + } +} + +export default async function BlogPost({ params }: { params: { slug: string } }) { + const post = await getPost(params.slug) + + if (!post) { + notFound() + } + + return ( +
+

{post.title}

+ +
+
+ ) +} +``` + +## 마무리 + +App Router에서 `generateStaticParams`를 사용하면 빌드 시점에 정적 페이지들을 미리 생성하여 성능을 향상시킬 수 있습니다. 동시에 적절한 404 페이지 처리를 통해 사용자 경험을 개선할 수 있습니다. + +주요 포인트: +- `generateStaticParams`로 정적 매개변수 생성 +- `notFound()` 함수로 404 페이지 처리 +- `dynamicParams` 옵션으로 동적 매개변수 허용 여부 제어 +- 적절한 에러 처리와 재검증 설정으로 안정성 확보 + diff --git a/tsconfig.json b/tsconfig.json index d2bba22..b92e56f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,6 +25,9 @@ "paths": { "@/*": [ "./src/*" + ], + "@/app/*": [ + "./src/app/*" ] } },