diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2752430..37a6ee8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,12 +32,5 @@ jobs: - 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 + - name: Run tests + run: pnpm test:run \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index af3b977..ef56b78 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,7 +2,7 @@ "biome.requireConfiguration": true, "editor.codeActionsOnSave": { "source.fixAll.biome": "explicit", - "source.organizeImports.biome": "explicit" + "source.fixAll.eslint": "never" }, "editor.defaultFormatter": "biomejs.biome", "editor.formatOnSave": true, diff --git a/CLAUDE.md b/CLAUDE.md index cfa4a1c..67ad26c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co 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. **IMPORTANT**: -- Do NOT run `pnpm build` during development tasks unless specifically requested by the user. The build process is mainly for final deployment verification. +- Do NOT run `pnpm dev` or `pnpm build` during development tasks unless specifically requested by the user. The build process is mainly for final deployment verification. - When implementing new pages or components, use placeholders instead of actual content. Show what type of content should go in each position rather than writing fake content. - Do NOT create UI structures arbitrarily. Always ask the user for specific requirements and approval before implementing any UI design or structure. @@ -163,7 +163,7 @@ All client-side code is organized under `src/app/` following Next.js App Router - `page.tsx` - Home page - `not-found.tsx` - 404 error page - `globals.css` - Global styles -- `src/api/` - Server-side utilities (outside app directory) +- `src/entities/` - Domain entities and business logic (outside app directory) - `src/contents/` - MDX blog posts (*.mdx files) **Naming Convention:** @@ -173,12 +173,26 @@ All client-side code is organized under `src/app/` following Next.js App Router - Page/Feature scope: `src/app/[route]/_components/`, `src/app/[route]/_hooks/` - This ensures components and logic are co-located with their usage while maintaining clear boundaries +### Domain Architecture + +This project follows Domain-Driven Design principles with entities organized in `src/entities/`: + +- `src/entities/posts/` - Blog post domain logic + - Repository pattern for data access + - Post entity with type definitions + - Business logic for post operations +- `src/entities/tags/` - Tag system domain logic + - Tag entity and graph relationships + - Tag operations and queries + - Graph-based tag analysis + ### 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 +Blog posts are stored as MDX files in `src/contents/`. Each MDX file contains: +- **Frontmatter**: YAML metadata with `title`, `date`, `slug`, and `tags` information +- **Content**: Markdown content with JSX component support + +The post entity in `src/entities/posts/` handles parsing and processing of these MDX files. ### Static Generation diff --git a/next.config.ts b/next.config.ts index abe7c71..2d3f6b3 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,8 @@ import createMDX from '@next/mdx' import type { NextConfig } from 'next' import rehypePrettyCode, { type Options } from 'rehype-pretty-code' +import remarkFrontmatter from 'remark-frontmatter' +import remarkMdxFrontmatter from 'remark-mdx-frontmatter' const nextConfig: NextConfig = { pageExtensions: [ @@ -17,6 +19,10 @@ const nextConfig: NextConfig = { const withMDX = createMDX({ extension: /\.(mdx)$/, options: { + remarkPlugins: [ + remarkFrontmatter, + remarkMdxFrontmatter, + ], rehypePlugins: [ [ rehypePrettyCode, diff --git a/package.json b/package.json index 664df37..1a63d2d 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,10 @@ "dev": "next dev", "build": "next build", "start": "next start", + "test": "vitest", + "test:ui": "vitest --ui", + "test:run": "vitest run", + "test:coverage": "vitest run --coverage", "biome:check": "biome check --verbose", "biome:fix": "biome check --write --verbose", "biome:staged": "biome check --write --staged" @@ -19,12 +23,16 @@ "@types/mdx": "^2.0.13", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "graphology": "^0.26.0", + "graphology-types": "^0.24.8", "gray-matter": "^4.0.3", "lucide-react": "^0.525.0", "next": "15.3.5", "react": "^19.1.0", "react-dom": "^19.1.0", "rehype-pretty-code": "^0.14.1", + "remark-frontmatter": "^5.0.0", + "remark-mdx-frontmatter": "^5.2.0", "tailwind-merge": "^3.3.1" }, "devDependencies": { @@ -34,10 +42,13 @@ "@types/node": "^20.19.7", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", + "@vitest/coverage-v8": "3.2.4", + "@vitest/ui": "^3.2.4", "lefthook": "^1.12.2", "tailwindcss": "^4.1.11", "tw-animate-css": "^1.3.5", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "vitest": "^3.2.4" }, "packageManager": "pnpm@10.13.1+sha512.37ebf1a5c7a30d5fabe0c5df44ee8da4c965ca0c5af3dbab28c3a1681b70a256218d05c81c9c0dcf767ef6b8551eb5b960042b9ed4300c59242336377e01cfad" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc57697..bde561c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,12 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + graphology: + specifier: ^0.26.0 + version: 0.26.0(graphology-types@0.24.8) + graphology-types: + specifier: ^0.24.8 + version: 0.24.8 gray-matter: specifier: ^4.0.3 version: 4.0.3 @@ -50,6 +56,12 @@ importers: rehype-pretty-code: specifier: ^0.14.1 version: 0.14.1(shiki@3.7.0) + remark-frontmatter: + specifier: ^5.0.0 + version: 5.0.0 + remark-mdx-frontmatter: + specifier: ^5.2.0 + version: 5.2.0 tailwind-merge: specifier: ^3.3.1 version: 3.3.1 @@ -72,6 +84,12 @@ importers: '@types/react-dom': specifier: ^19.1.6 version: 19.1.6(@types/react@19.1.8) + '@vitest/coverage-v8': + specifier: 3.2.4 + version: 3.2.4(vitest@3.2.4) + '@vitest/ui': + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4) lefthook: specifier: ^1.12.2 version: 1.12.2 @@ -84,6 +102,9 @@ importers: typescript: specifier: ^5.8.3 version: 5.8.3 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.7)(@vitest/ui@3.2.4)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0) packages: @@ -95,6 +116,27 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.27.1': + resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.0': + resolution: {integrity: sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.28.1': + resolution: {integrity: sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@biomejs/biome@2.1.1': resolution: {integrity: sha512-HFGYkxG714KzG+8tvtXCJ1t1qXQMzgWzfvQaUjxN6UeKv+KvMEuliInnbZLJm6DXFXwqVi6446EGI0sGBLIYng==} engines: {node: '>=14.21.3'} @@ -151,6 +193,162 @@ packages: '@emnapi/runtime@1.4.4': resolution: {integrity: sha512-hHyapA4A3gPaDCNfiqyZUStTMqIkKRshqPIuDOXv1hcBnD4U3l8cP0T1HMCfGRxQ6V64TGCcoswChANyOAwbQg==} + '@esbuild/aix-ppc64@0.25.6': + resolution: {integrity: sha512-ShbM/3XxwuxjFiuVBHA+d3j5dyac0aEVVq1oluIDf71hUw0aRF59dV/efUsIwFnR6m8JNM2FjZOzmaZ8yG61kw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.6': + resolution: {integrity: sha512-hd5zdUarsK6strW+3Wxi5qWws+rJhCCbMiC9QZyzoxfk5uHRIE8T287giQxzVpEvCwuJ9Qjg6bEjcRJcgfLqoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.6': + resolution: {integrity: sha512-S8ToEOVfg++AU/bHwdksHNnyLyVM+eMVAOf6yRKFitnwnbwwPNqKr3srzFRe7nzV69RQKb5DgchIX5pt3L53xg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.6': + resolution: {integrity: sha512-0Z7KpHSr3VBIO9A/1wcT3NTy7EB4oNC4upJ5ye3R7taCc2GUdeynSLArnon5G8scPwaU866d3H4BCrE5xLW25A==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.6': + resolution: {integrity: sha512-FFCssz3XBavjxcFxKsGy2DYK5VSvJqa6y5HXljKzhRZ87LvEi13brPrf/wdyl/BbpbMKJNOr1Sd0jtW4Ge1pAA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.6': + resolution: {integrity: sha512-GfXs5kry/TkGM2vKqK2oyiLFygJRqKVhawu3+DOCk7OxLy/6jYkWXhlHwOoTb0WqGnWGAS7sooxbZowy+pK9Yg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.6': + resolution: {integrity: sha512-aoLF2c3OvDn2XDTRvn8hN6DRzVVpDlj2B/F66clWd/FHLiHaG3aVZjxQX2DYphA5y/evbdGvC6Us13tvyt4pWg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.6': + resolution: {integrity: sha512-2SkqTjTSo2dYi/jzFbU9Plt1vk0+nNg8YC8rOXXea+iA3hfNJWebKYPs3xnOUf9+ZWhKAaxnQNUf2X9LOpeiMQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.6': + resolution: {integrity: sha512-b967hU0gqKd9Drsh/UuAm21Khpoh6mPBSgz8mKRq4P5mVK8bpA+hQzmm/ZwGVULSNBzKdZPQBRT3+WuVavcWsQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.6': + resolution: {integrity: sha512-SZHQlzvqv4Du5PrKE2faN0qlbsaW/3QQfUUc6yO2EjFcA83xnwm91UbEEVx4ApZ9Z5oG8Bxz4qPE+HFwtVcfyw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.6': + resolution: {integrity: sha512-aHWdQ2AAltRkLPOsKdi3xv0mZ8fUGPdlKEjIEhxCPm5yKEThcUjHpWB1idN74lfXGnZ5SULQSgtr5Qos5B0bPw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.6': + resolution: {integrity: sha512-VgKCsHdXRSQ7E1+QXGdRPlQ/e08bN6WMQb27/TMfV+vPjjTImuT9PmLXupRlC90S1JeNNW5lzkAEO/McKeJ2yg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.6': + resolution: {integrity: sha512-WViNlpivRKT9/py3kCmkHnn44GkGXVdXfdc4drNmRl15zVQ2+D2uFwdlGh6IuK5AAnGTo2qPB1Djppj+t78rzw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.6': + resolution: {integrity: sha512-wyYKZ9NTdmAMb5730I38lBqVu6cKl4ZfYXIs31Baf8aoOtB4xSGi3THmDYt4BTFHk7/EcVixkOV2uZfwU3Q2Jw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.6': + resolution: {integrity: sha512-KZh7bAGGcrinEj4qzilJ4hqTY3Dg2U82c8bv+e1xqNqZCrCyc+TL9AUEn5WGKDzm3CfC5RODE/qc96OcbIe33w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.6': + resolution: {integrity: sha512-9N1LsTwAuE9oj6lHMyyAM+ucxGiVnEqUdp4v7IaMmrwb06ZTEVCIs3oPPplVsnjPfyjmxwHxHMF8b6vzUVAUGw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.6': + resolution: {integrity: sha512-A6bJB41b4lKFWRKNrWoP2LHsjVzNiaurf7wyj/XtFNTsnPuxwEBWHLty+ZE0dWBKuSK1fvKgrKaNjBS7qbFKig==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.6': + resolution: {integrity: sha512-IjA+DcwoVpjEvyxZddDqBY+uJ2Snc6duLpjmkXm/v4xuS3H+3FkLZlDm9ZsAbF9rsfP3zeA0/ArNDORZgrxR/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.6': + resolution: {integrity: sha512-dUXuZr5WenIDlMHdMkvDc1FAu4xdWixTCRgP7RQLBOkkGgwuuzaGSYcOpW4jFxzpzL1ejb8yF620UxAqnBrR9g==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.6': + resolution: {integrity: sha512-l8ZCvXP0tbTJ3iaqdNf3pjaOSd5ex/e6/omLIQCVBLmHTlfXW3zAxQ4fnDmPLOB1x9xrcSi/xtCWFwCZRIaEwg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.6': + resolution: {integrity: sha512-hKrmDa0aOFOr71KQ/19JC7az1P0GWtCN1t2ahYAf4O007DHZt/dW8ym5+CUdJhQ/qkZmI1HAF8KkJbEFtCL7gw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.6': + resolution: {integrity: sha512-+SqBcAWoB1fYKmpWoQP4pGtx+pUUC//RNYhFdbcSA16617cchuryuhOCRpPsjCblKukAckWsV+aQ3UKT/RMPcA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.6': + resolution: {integrity: sha512-dyCGxv1/Br7MiSC42qinGL8KkG4kX0pEsdb0+TKhmJZgCUDBGmyo1/ArCjNGiOLiIAgdbWgmWgib4HoCi5t7kA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.6': + resolution: {integrity: sha512-42QOgcZeZOvXfsCBJF5Afw73t4veOId//XD3i+/9gSkhSV6Gk3VPlWncctI+JcOyERv85FUo7RxuxGy+z8A43Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.6': + resolution: {integrity: sha512-4AWhgXmDuYN7rJI6ORB+uU9DHLq/erBbuMoAuB4VWJTu5KtCgcKYPynF0YI1VkBNuEfjNlLrFr9KZPJzrtLkrQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.6': + resolution: {integrity: sha512-NgJPHHbEpLQgDH2MjQu90pzW/5vvXIZ7KOnPyNBm92A6WgZ/7b6fJyUBjoumLqeOQQGqY2QjQxRo97ah4Sj0cA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@img/sharp-darwin-arm64@0.34.3': resolution: {integrity: sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -273,10 +471,18 @@ packages: cpu: [x64] os: [win32] + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + '@jridgewell/gen-mapping@0.3.12': resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==} @@ -369,6 +575,13 @@ packages: cpu: [x64] os: [win32] + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@radix-ui/react-compose-refs@1.1.2': resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} peerDependencies: @@ -413,6 +626,106 @@ packages: '@types/react': optional: true + '@rollup/rollup-android-arm-eabi@4.45.0': + resolution: {integrity: sha512-2o/FgACbji4tW1dzXOqAV15Eu7DdgbKsF2QKcxfG4xbh5iwU7yr5RRP5/U+0asQliSYv5M4o7BevlGIoSL0LXg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.45.0': + resolution: {integrity: sha512-PSZ0SvMOjEAxwZeTx32eI/j5xSYtDCRxGu5k9zvzoY77xUNssZM+WV6HYBLROpY5CkXsbQjvz40fBb7WPwDqtQ==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.45.0': + resolution: {integrity: sha512-BA4yPIPssPB2aRAWzmqzQ3y2/KotkLyZukVB7j3psK/U3nVJdceo6qr9pLM2xN6iRP/wKfxEbOb1yrlZH6sYZg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.45.0': + resolution: {integrity: sha512-Pr2o0lvTwsiG4HCr43Zy9xXrHspyMvsvEw4FwKYqhli4FuLE5FjcZzuQ4cfPe0iUFCvSQG6lACI0xj74FDZKRA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.45.0': + resolution: {integrity: sha512-lYE8LkE5h4a/+6VnnLiL14zWMPnx6wNbDG23GcYFpRW1V9hYWHAw9lBZ6ZUIrOaoK7NliF1sdwYGiVmziUF4vA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.45.0': + resolution: {integrity: sha512-PVQWZK9sbzpvqC9Q0GlehNNSVHR+4m7+wET+7FgSnKG3ci5nAMgGmr9mGBXzAuE5SvguCKJ6mHL6vq1JaJ/gvw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.45.0': + resolution: {integrity: sha512-hLrmRl53prCcD+YXTfNvXd776HTxNh8wPAMllusQ+amcQmtgo3V5i/nkhPN6FakW+QVLoUUr2AsbtIRPFU3xIA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.45.0': + resolution: {integrity: sha512-XBKGSYcrkdiRRjl+8XvrUR3AosXU0NvF7VuqMsm7s5nRy+nt58ZMB19Jdp1RdqewLcaYnpk8zeVs/4MlLZEJxw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.45.0': + resolution: {integrity: sha512-fRvZZPUiBz7NztBE/2QnCS5AtqLVhXmUOPj9IHlfGEXkapgImf4W9+FSkL8cWqoAjozyUzqFmSc4zh2ooaeF6g==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.45.0': + resolution: {integrity: sha512-Btv2WRZOcUGi8XU80XwIvzTg4U6+l6D0V6sZTrZx214nrwxw5nAi8hysaXj/mctyClWgesyuxbeLylCBNauimg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loongarch64-gnu@4.45.0': + resolution: {integrity: sha512-Li0emNnwtUZdLwHjQPBxn4VWztcrw/h7mgLyHiEI5Z0MhpeFGlzaiBHpSNVOMB/xucjXTTcO+dhv469Djr16KA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-powerpc64le-gnu@4.45.0': + resolution: {integrity: sha512-sB8+pfkYx2kvpDCfd63d5ScYT0Fz1LO6jIb2zLZvmK9ob2D8DeVqrmBDE0iDK8KlBVmsTNzrjr3G1xV4eUZhSw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.45.0': + resolution: {integrity: sha512-5GQ6PFhh7E6jQm70p1aW05G2cap5zMOvO0se5JMecHeAdj5ZhWEHbJ4hiKpfi1nnnEdTauDXxPgXae/mqjow9w==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.45.0': + resolution: {integrity: sha512-N/euLsBd1rekWcuduakTo/dJw6U6sBP3eUq+RXM9RNfPuWTvG2w/WObDkIvJ2KChy6oxZmOSC08Ak2OJA0UiAA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.45.0': + resolution: {integrity: sha512-2l9sA7d7QdikL0xQwNMO3xURBUNEWyHVHfAsHsUdq+E/pgLTUcCE+gih5PCdmyHmfTDeXUWVhqL0WZzg0nua3g==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.45.0': + resolution: {integrity: sha512-XZdD3fEEQcwG2KrJDdEQu7NrHonPxxaV0/w2HpvINBdcqebz1aL+0vM2WFJq4DeiAVT6F5SUQas65HY5JDqoPw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.45.0': + resolution: {integrity: sha512-7ayfgvtmmWgKWBkCGg5+xTQ0r5V1owVm67zTrsEY1008L5ro7mCyGYORomARt/OquB9KY7LpxVBZes+oSniAAQ==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-win32-arm64-msvc@4.45.0': + resolution: {integrity: sha512-B+IJgcBnE2bm93jEW5kHisqvPITs4ddLOROAcOc/diBgrEiQJJ6Qcjby75rFSmH5eMGrqJryUgJDhrfj942apQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.45.0': + resolution: {integrity: sha512-+CXwwG66g0/FpWOnP/v1HnrGVSOygK/osUbu3wPRy8ECXjoYKjRAyfxYpDQOfghC5qPJYLPH0oN4MCOjwgdMug==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.45.0': + resolution: {integrity: sha512-SRf1cytG7wqcHVLrBc9VtPK4pU5wxiB/lNIkNmW2ApKXIg+RpqwHfsaEK+e7eH4A1BpI6BX/aBWXxZCIrJg3uA==} + cpu: [x64] + os: [win32] + '@shikijs/core@3.7.0': resolution: {integrity: sha512-yilc0S9HvTPyahHpcum8eonYrQtmGTU0lbtwxhA6jHv4Bm1cAdlPFRCJX4AHebkCm75aKTjjRAW+DezqD1b/cg==} @@ -533,9 +846,15 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + '@types/chai@5.2.2': + resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -574,6 +893,49 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@vitest/coverage-v8@3.2.4': + resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} + peerDependencies: + '@vitest/browser': 3.2.4 + vitest: 3.2.4 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/ui@3.2.4': + resolution: {integrity: sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==} + peerDependencies: + vitest: 3.2.4 + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -584,9 +946,32 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.1.0: + resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@0.3.3: + resolution: {integrity: sha512-MuXMrSLVVoA6sYN/6Hke18vMzrT4TZNbZIj/hvh0fnYFpO+/kFXcLIaiPwXXWaQUPg4yJD8fj+lfJ7/1EBconw==} + astring@1.9.0: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true @@ -594,16 +979,30 @@ packages: bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + busboy@1.6.0: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + caniuse-lite@1.0.30001727: resolution: {integrity: sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@5.2.1: + resolution: {integrity: sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A==} + engines: {node: '>=18'} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -616,6 +1015,10 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + check-error@2.1.1: + resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} + engines: {node: '>= 16'} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -650,6 +1053,10 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -670,6 +1077,10 @@ packages: decode-named-character-reference@1.2.0: resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -681,6 +1092,15 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + enhanced-resolve@5.18.2: resolution: {integrity: sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==} engines: {node: '>=10.13.0'} @@ -689,12 +1109,24 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + esast-util-from-estree@2.0.0: resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} esast-util-from-js@2.0.1: resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} + esbuild@0.25.6: + resolution: {integrity: sha512-GVuzuUwtdsghE3ocJ9Bs8PNoF13HNQ5TXbEi2AhvVb8xU1Iwt9Fos9FEamfoee+u/TOsn7GUWc04lz46n2bbTg==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} @@ -715,12 +1147,23 @@ packages: estree-util-to-js@2.0.0: resolution: {integrity: sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==} + estree-util-value-to-estree@3.4.0: + resolution: {integrity: sha512-Zlp+gxis+gCfK12d3Srl2PdX2ybsEA8ZYy6vQGVQTNNYLEGRQQ56XB64bjemN8kxIKXP1nC9ip4Z+ILy9LGzvQ==} + estree-util-visit@2.0.0: resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + expect-type@1.2.2: + resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} + engines: {node: '>=12.0.0'} + extend-shallow@2.0.1: resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} engines: {node: '>=0.10.0'} @@ -728,13 +1171,59 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fault@2.0.1: + resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} + + fdir@6.4.6: + resolution: {integrity: sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + format@0.2.2: + resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} + engines: {node: '>=0.4.x'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + hasBin: true + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphology-types@0.24.8: + resolution: {integrity: sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==} + + graphology@0.26.0: + resolution: {integrity: sha512-8SSImzgUUYC89Z042s+0r/vMibY7GX/Emz4LDO5e7jYXhuoWfHISPFJYjpRLUSJGq6UQ6xlenvX1p/hJdfXuXg==} + peerDependencies: + graphology-types: '>=0.24.0' + gray-matter@4.0.3: resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} engines: {node: '>=6.0'} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + hast-util-from-html@2.0.3: resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} @@ -762,6 +1251,9 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} @@ -784,6 +1276,10 @@ packages: resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} @@ -791,10 +1287,35 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.1.7: + resolution: {integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jiti@2.4.2: resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} hasBin: true + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@3.14.1: resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} hasBin: true @@ -933,6 +1454,12 @@ packages: longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + loupe@3.1.4: + resolution: {integrity: sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lucide-react@0.525.0: resolution: {integrity: sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==} peerDependencies: @@ -941,6 +1468,13 @@ packages: magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + markdown-extensions@2.0.0: resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} engines: {node: '>=16'} @@ -948,6 +1482,9 @@ packages: mdast-util-from-markdown@2.0.2: resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==} + mdast-util-frontmatter@2.0.1: + resolution: {integrity: sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==} + mdast-util-mdx-expression@2.0.1: resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} @@ -975,6 +1512,9 @@ packages: micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + micromark-extension-frontmatter@2.0.0: + resolution: {integrity: sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==} + micromark-extension-mdx-expression@3.0.1: resolution: {integrity: sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==} @@ -1056,6 +1596,10 @@ packages: micromark@4.0.2: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + minipass@7.1.2: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} @@ -1069,6 +1613,10 @@ packages: engines: {node: '>=10'} hasBin: true + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1104,6 +1652,9 @@ packages: oniguruma-to-es@4.3.3: resolution: {integrity: sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} @@ -1113,9 +1664,28 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@4.0.2: + resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} + engines: {node: '>=12'} + postcss-selector-parser@6.0.10: resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} engines: {node: '>=4'} @@ -1173,6 +1743,12 @@ packages: rehype-recma@1.0.0: resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==} + remark-frontmatter@5.0.0: + resolution: {integrity: sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==} + + remark-mdx-frontmatter@5.2.0: + resolution: {integrity: sha512-U/hjUYTkQqNjjMRYyilJgLXSPF65qbLPdoESOkXyrwz2tVyhAnm4GUKhfXqOOS9W34M3545xEMq+aMpHgVjEeQ==} + remark-mdx@3.1.0: resolution: {integrity: sha512-Ngl/H3YXyBV9RcRNdlYsZujAmhsxwzxpDzpDEhFBVAGthS4GDgnctpDjgFl/ULx5UEDzqtW1cyBSNKqYYrqLBA==} @@ -1182,6 +1758,11 @@ packages: remark-rehype@11.1.2: resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + rollup@4.45.0: + resolution: {integrity: sha512-WLjEcJRIo7i3WDDgOIJqVI2d+lAC3EwvOGy+Xfq6hs+GQuAA4Di/H72xmXkOhrIWFg2PFYSKZYfH0f4vfKXN4A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + scheduler@0.26.0: resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} @@ -1198,12 +1779,31 @@ packages: resolution: {integrity: sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + shiki@3.7.0: resolution: {integrity: sha512-ZcI4UT9n6N2pDuM2n3Jbk0sR4Swzq43nLPgS/4h0E3B/NrFn2HKElrDtceSf8Zx/OWYOo7G1SAtBLypCp+YXqg==} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + simple-swizzle@0.2.2: resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + sirv@3.0.1: + resolution: {integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==} + engines: {node: '>=18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1218,17 +1818,42 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.9.0: + resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} + streamsearch@1.1.0: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + strip-bom-string@1.0.0: resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} engines: {node: '>=0.10.0'} + strip-literal@3.0.0: + resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} + style-to-js@1.1.17: resolution: {integrity: sha512-xQcBGDxJb6jjFCTzvQtfiPn6YvvP2O8U1MDIPNfJQlWMYfktPy+iGsHE7cssjs7y84d9fQaK4UF3RIJaAHSoYA==} @@ -1248,6 +1873,10 @@ packages: babel-plugin-macros: optional: true + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + tailwind-merge@3.3.1: resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==} @@ -1262,6 +1891,39 @@ packages: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} + test-exclude@7.0.1: + resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} + engines: {node: '>=18'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.14: + resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.3: + resolution: {integrity: sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==} + engines: {node: '>=14.0.0'} + + toml@3.0.0: + resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -1288,6 +1950,9 @@ packages: unist-util-is@6.0.0: resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==} + unist-util-mdx-define@1.1.2: + resolution: {integrity: sha512-9ncH7i7TN5Xn7/tzX5bE3rXgz1X/u877gYVAUB3mLeTKYJmQHmqKTDBi6BTGXV7AeolBCI9ErcVsOt2qryoD0g==} + unist-util-position-from-estree@2.0.0: resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==} @@ -1315,13 +1980,109 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.0.4: + resolution: {integrity: sha512-SkaSguuS7nnmV7mfJ8l81JGBFV7Gvzp8IzgE8A8t23+AxuNX61Q5H1Tpz5efduSN7NHC8nQXD3sKQKZAu5mNEA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + yallist@5.0.0: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} + yaml@2.8.0: + resolution: {integrity: sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==} + engines: {node: '>= 14.6'} + hasBin: true + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -1334,6 +2095,21 @@ snapshots: '@jridgewell/gen-mapping': 0.3.12 '@jridgewell/trace-mapping': 0.3.29 + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.27.1': {} + + '@babel/parser@7.28.0': + dependencies: + '@babel/types': 7.28.1 + + '@babel/types@7.28.1': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.27.1 + + '@bcoe/v8-coverage@1.0.2': {} + '@biomejs/biome@2.1.1': optionalDependencies: '@biomejs/cli-darwin-arm64': 2.1.1 @@ -1374,6 +2150,84 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.25.6': + optional: true + + '@esbuild/android-arm64@0.25.6': + optional: true + + '@esbuild/android-arm@0.25.6': + optional: true + + '@esbuild/android-x64@0.25.6': + optional: true + + '@esbuild/darwin-arm64@0.25.6': + optional: true + + '@esbuild/darwin-x64@0.25.6': + optional: true + + '@esbuild/freebsd-arm64@0.25.6': + optional: true + + '@esbuild/freebsd-x64@0.25.6': + optional: true + + '@esbuild/linux-arm64@0.25.6': + optional: true + + '@esbuild/linux-arm@0.25.6': + optional: true + + '@esbuild/linux-ia32@0.25.6': + optional: true + + '@esbuild/linux-loong64@0.25.6': + optional: true + + '@esbuild/linux-mips64el@0.25.6': + optional: true + + '@esbuild/linux-ppc64@0.25.6': + optional: true + + '@esbuild/linux-riscv64@0.25.6': + optional: true + + '@esbuild/linux-s390x@0.25.6': + optional: true + + '@esbuild/linux-x64@0.25.6': + optional: true + + '@esbuild/netbsd-arm64@0.25.6': + optional: true + + '@esbuild/netbsd-x64@0.25.6': + optional: true + + '@esbuild/openbsd-arm64@0.25.6': + optional: true + + '@esbuild/openbsd-x64@0.25.6': + optional: true + + '@esbuild/openharmony-arm64@0.25.6': + optional: true + + '@esbuild/sunos-x64@0.25.6': + optional: true + + '@esbuild/win32-arm64@0.25.6': + optional: true + + '@esbuild/win32-ia32@0.25.6': + optional: true + + '@esbuild/win32-x64@0.25.6': + optional: true + '@img/sharp-darwin-arm64@0.34.3': optionalDependencies: '@img/sharp-libvips-darwin-arm64': 1.2.0 @@ -1460,10 +2314,21 @@ snapshots: '@img/sharp-win32-x64@0.34.3': optional: true + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/fs-minipass@4.0.1': dependencies: minipass: 7.1.2 + '@istanbuljs/schema@0.1.3': {} + '@jridgewell/gen-mapping@0.3.12': dependencies: '@jridgewell/sourcemap-codec': 1.5.4 @@ -1555,6 +2420,11 @@ snapshots: '@next/swc-win32-x64-msvc@15.3.5': optional: true + '@pkgjs/parseargs@0.11.0': + optional: true + + '@polka/url@1.0.0-next.29': {} + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.1.8)(react@19.1.0)': dependencies: react: 19.1.0 @@ -1586,6 +2456,66 @@ snapshots: optionalDependencies: '@types/react': 19.1.8 + '@rollup/rollup-android-arm-eabi@4.45.0': + optional: true + + '@rollup/rollup-android-arm64@4.45.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.45.0': + optional: true + + '@rollup/rollup-darwin-x64@4.45.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.45.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.45.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.45.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.45.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.45.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.45.0': + optional: true + + '@rollup/rollup-linux-loongarch64-gnu@4.45.0': + optional: true + + '@rollup/rollup-linux-powerpc64le-gnu@4.45.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.45.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.45.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.45.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.45.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.45.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.45.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.45.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.45.0': + optional: true + '@shikijs/core@3.7.0': dependencies: '@shikijs/types': 3.7.0 @@ -1705,10 +2635,16 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.1.11 + '@types/chai@5.2.2': + dependencies: + '@types/deep-eql': 4.0.2 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -1745,28 +2681,134 @@ snapshots: '@ungap/structured-clone@1.3.0': {} + '@vitest/coverage-v8@3.2.4(vitest@3.2.4)': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 1.0.2 + ast-v8-to-istanbul: 0.3.3 + debug: 4.4.1 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.1.7 + magic-string: 0.30.17 + magicast: 0.3.5 + std-env: 3.9.0 + test-exclude: 7.0.1 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.7)(@vitest/ui@3.2.4)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.2 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.2.1 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@7.0.4(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.17 + optionalDependencies: + vite: 7.0.4(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.0.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.17 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.3 + + '@vitest/ui@3.2.4(vitest@3.2.4)': + dependencies: + '@vitest/utils': 3.2.4 + fflate: 0.8.2 + flatted: 3.3.3 + pathe: 2.0.3 + sirv: 3.0.1 + tinyglobby: 0.2.14 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.7)(@vitest/ui@3.2.4)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0) + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.1.4 + tinyrainbow: 2.0.0 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 acorn@8.15.0: {} + ansi-regex@5.0.1: {} + + ansi-regex@6.1.0: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.1: {} + argparse@1.0.10: dependencies: sprintf-js: 1.0.3 + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@0.3.3: + dependencies: + '@jridgewell/trace-mapping': 0.3.29 + estree-walker: 3.0.3 + js-tokens: 9.0.1 + astring@1.9.0: {} bail@2.0.2: {} + balanced-match@1.0.2: {} + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + busboy@1.6.0: dependencies: streamsearch: 1.1.0 + cac@6.7.14: {} + caniuse-lite@1.0.30001727: {} ccount@2.0.1: {} + chai@5.2.1: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.1 + deep-eql: 5.0.2 + loupe: 3.1.4 + pathval: 2.0.1 + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -1775,6 +2817,8 @@ snapshots: character-reference-invalid@2.0.1: {} + check-error@2.1.1: {} + chownr@3.0.0: {} class-variance-authority@0.7.1: @@ -1790,10 +2834,8 @@ snapshots: color-convert@2.0.1: dependencies: color-name: 1.1.4 - optional: true - color-name@1.1.4: - optional: true + color-name@1.1.4: {} color-string@1.9.1: dependencies: @@ -1809,6 +2851,12 @@ snapshots: comma-separated-tokens@2.0.3: {} + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + cssesc@3.0.0: {} csstype@3.1.3: {} @@ -1821,6 +2869,8 @@ snapshots: dependencies: character-entities: 2.0.2 + deep-eql@5.0.2: {} + dequal@2.0.3: {} detect-libc@2.0.4: {} @@ -1829,6 +2879,12 @@ snapshots: dependencies: dequal: 2.0.3 + eastasianwidth@0.2.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + enhanced-resolve@5.18.2: dependencies: graceful-fs: 4.2.11 @@ -1836,6 +2892,8 @@ snapshots: entities@6.0.1: {} + es-module-lexer@1.7.0: {} + esast-util-from-estree@2.0.0: dependencies: '@types/estree-jsx': 1.0.5 @@ -1850,6 +2908,37 @@ snapshots: esast-util-from-estree: 2.0.0 vfile-message: 4.0.2 + esbuild@0.25.6: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.6 + '@esbuild/android-arm': 0.25.6 + '@esbuild/android-arm64': 0.25.6 + '@esbuild/android-x64': 0.25.6 + '@esbuild/darwin-arm64': 0.25.6 + '@esbuild/darwin-x64': 0.25.6 + '@esbuild/freebsd-arm64': 0.25.6 + '@esbuild/freebsd-x64': 0.25.6 + '@esbuild/linux-arm': 0.25.6 + '@esbuild/linux-arm64': 0.25.6 + '@esbuild/linux-ia32': 0.25.6 + '@esbuild/linux-loong64': 0.25.6 + '@esbuild/linux-mips64el': 0.25.6 + '@esbuild/linux-ppc64': 0.25.6 + '@esbuild/linux-riscv64': 0.25.6 + '@esbuild/linux-s390x': 0.25.6 + '@esbuild/linux-x64': 0.25.6 + '@esbuild/netbsd-arm64': 0.25.6 + '@esbuild/netbsd-x64': 0.25.6 + '@esbuild/openbsd-arm64': 0.25.6 + '@esbuild/openbsd-x64': 0.25.6 + '@esbuild/openharmony-arm64': 0.25.6 + '@esbuild/sunos-x64': 0.25.6 + '@esbuild/win32-arm64': 0.25.6 + '@esbuild/win32-ia32': 0.25.6 + '@esbuild/win32-x64': 0.25.6 + + escape-string-regexp@5.0.0: {} + esprima@4.0.1: {} estree-util-attach-comments@3.0.0: @@ -1876,6 +2965,10 @@ snapshots: astring: 1.9.0 source-map: 0.7.4 + estree-util-value-to-estree@3.4.0: + dependencies: + '@types/estree': 1.0.8 + estree-util-visit@2.0.0: dependencies: '@types/estree-jsx': 1.0.5 @@ -1885,14 +2978,56 @@ snapshots: dependencies: '@types/estree': 1.0.8 + events@3.3.0: {} + + expect-type@1.2.2: {} + extend-shallow@2.0.1: dependencies: is-extendable: 0.1.1 extend@3.0.2: {} + fault@2.0.1: + dependencies: + format: 0.2.2 + + fdir@6.4.6(picomatch@4.0.2): + optionalDependencies: + picomatch: 4.0.2 + + fflate@0.8.2: {} + + flatted@3.3.3: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + format@0.2.2: {} + + fsevents@2.3.3: + optional: true + + glob@10.4.5: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + graceful-fs@4.2.11: {} + graphology-types@0.24.8: {} + + graphology@0.26.0(graphology-types@0.24.8): + dependencies: + events: 3.3.0 + graphology-types: 0.24.8 + gray-matter@4.0.3: dependencies: js-yaml: 3.14.1 @@ -1900,6 +3035,8 @@ snapshots: section-matter: 1.0.0 strip-bom-string: 1.0.0 + has-flag@4.0.0: {} + hast-util-from-html@2.0.3: dependencies: '@types/hast': 3.0.4 @@ -1995,6 +3132,8 @@ snapshots: property-information: 7.1.0 space-separated-tokens: 2.0.2 + html-escaper@2.0.2: {} + html-void-elements@3.0.0: {} inline-style-parser@0.2.4: {} @@ -2013,12 +3152,45 @@ snapshots: is-extendable@0.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-hexadecimal@2.0.1: {} is-plain-obj@4.1.0: {} + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.29 + debug: 4.4.1 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.1.7: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jiti@2.4.2: {} + js-tokens@9.0.1: {} + js-yaml@3.14.1: dependencies: argparse: 1.0.10 @@ -2122,6 +3294,10 @@ snapshots: longest-streak@3.1.0: {} + loupe@3.1.4: {} + + lru-cache@10.4.3: {} + lucide-react@0.525.0(react@19.1.0): dependencies: react: 19.1.0 @@ -2130,6 +3306,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.4 + magicast@0.3.5: + dependencies: + '@babel/parser': 7.28.0 + '@babel/types': 7.28.1 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.2 + markdown-extensions@2.0.0: {} mdast-util-from-markdown@2.0.2: @@ -2149,6 +3335,17 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-frontmatter@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + escape-string-regexp: 5.0.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + micromark-extension-frontmatter: 2.0.0 + transitivePeerDependencies: + - supports-color + mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 @@ -2250,6 +3447,13 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-frontmatter@2.0.0: + dependencies: + fault: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + micromark-extension-mdx-expression@3.0.1: dependencies: '@types/estree': 1.0.8 @@ -2437,6 +3641,10 @@ snapshots: transitivePeerDependencies: - supports-color + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + minipass@7.1.2: {} minizlib@3.0.2: @@ -2445,6 +3653,8 @@ snapshots: mkdirp@3.0.1: {} + mrmime@2.0.1: {} + ms@2.1.3: {} nanoid@3.3.11: {} @@ -2482,6 +3692,8 @@ snapshots: regex: 6.0.1 regex-recursion: 6.0.2 + package-json-from-dist@1.0.1: {} + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -2498,8 +3710,21 @@ snapshots: dependencies: entities: 6.0.1 + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + + pathe@2.0.3: {} + + pathval@2.0.1: {} + picocolors@1.1.1: {} + picomatch@4.0.2: {} + postcss-selector-parser@6.0.10: dependencies: cssesc: 3.0.0 @@ -2590,6 +3815,24 @@ snapshots: transitivePeerDependencies: - supports-color + remark-frontmatter@5.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-frontmatter: 2.0.1 + micromark-extension-frontmatter: 2.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-mdx-frontmatter@5.2.0: + dependencies: + '@types/mdast': 4.0.4 + estree-util-value-to-estree: 3.4.0 + toml: 3.0.0 + unified: 11.0.5 + unist-util-mdx-define: 1.1.2 + yaml: 2.8.0 + remark-mdx@3.1.0: dependencies: mdast-util-mdx: 3.0.0 @@ -2614,6 +3857,32 @@ snapshots: unified: 11.0.5 vfile: 6.0.3 + rollup@4.45.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.45.0 + '@rollup/rollup-android-arm64': 4.45.0 + '@rollup/rollup-darwin-arm64': 4.45.0 + '@rollup/rollup-darwin-x64': 4.45.0 + '@rollup/rollup-freebsd-arm64': 4.45.0 + '@rollup/rollup-freebsd-x64': 4.45.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.45.0 + '@rollup/rollup-linux-arm-musleabihf': 4.45.0 + '@rollup/rollup-linux-arm64-gnu': 4.45.0 + '@rollup/rollup-linux-arm64-musl': 4.45.0 + '@rollup/rollup-linux-loongarch64-gnu': 4.45.0 + '@rollup/rollup-linux-powerpc64le-gnu': 4.45.0 + '@rollup/rollup-linux-riscv64-gnu': 4.45.0 + '@rollup/rollup-linux-riscv64-musl': 4.45.0 + '@rollup/rollup-linux-s390x-gnu': 4.45.0 + '@rollup/rollup-linux-x64-gnu': 4.45.0 + '@rollup/rollup-linux-x64-musl': 4.45.0 + '@rollup/rollup-win32-arm64-msvc': 4.45.0 + '@rollup/rollup-win32-ia32-msvc': 4.45.0 + '@rollup/rollup-win32-x64-msvc': 4.45.0 + fsevents: 2.3.3 + scheduler@0.26.0: {} section-matter@1.0.0: @@ -2621,8 +3890,7 @@ snapshots: extend-shallow: 2.0.1 kind-of: 6.0.3 - semver@7.7.2: - optional: true + semver@7.7.2: {} sharp@0.34.3: dependencies: @@ -2654,6 +3922,12 @@ snapshots: '@img/sharp-win32-x64': 0.34.3 optional: true + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + shiki@3.7.0: dependencies: '@shikijs/core': 3.7.0 @@ -2665,11 +3939,21 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + simple-swizzle@0.2.2: dependencies: is-arrayish: 0.3.2 optional: true + sirv@3.0.1: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + source-map-js@1.2.1: {} source-map@0.7.4: {} @@ -2678,15 +3962,43 @@ snapshots: sprintf-js@1.0.3: {} + stackback@0.0.2: {} + + std-env@3.9.0: {} + streamsearch@1.1.0: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 character-entities-legacy: 3.0.0 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.0: + dependencies: + ansi-regex: 6.1.0 + strip-bom-string@1.0.0: {} + strip-literal@3.0.0: + dependencies: + js-tokens: 9.0.1 + style-to-js@1.1.17: dependencies: style-to-object: 1.0.9 @@ -2700,6 +4012,10 @@ snapshots: client-only: 0.0.1 react: 19.1.0 + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + tailwind-merge@3.3.1: {} tailwindcss@4.1.11: {} @@ -2715,6 +4031,31 @@ snapshots: mkdirp: 3.0.1 yallist: 5.0.0 + test-exclude@7.0.1: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 10.4.5 + minimatch: 9.0.5 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.14: + dependencies: + fdir: 6.4.6(picomatch@4.0.2) + picomatch: 4.0.2 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.3: {} + + toml@3.0.0: {} + + totalist@3.0.1: {} + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -2741,6 +4082,16 @@ snapshots: dependencies: '@types/unist': 3.0.3 + unist-util-mdx-define@1.1.2: + dependencies: + '@types/estree': 1.0.8 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + estree-util-is-identifier-name: 3.0.0 + estree-util-scope: 1.0.0 + estree-walker: 3.0.3 + vfile: 6.0.3 + unist-util-position-from-estree@2.0.0: dependencies: '@types/unist': 3.0.3 @@ -2781,8 +4132,110 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 + vite-node@3.2.4(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0): + dependencies: + cac: 6.7.14 + debug: 4.4.1 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.0.4(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.0.4(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0): + dependencies: + esbuild: 0.25.6 + fdir: 6.4.6(picomatch@4.0.2) + picomatch: 4.0.2 + postcss: 8.5.6 + rollup: 4.45.0 + tinyglobby: 0.2.14 + optionalDependencies: + '@types/node': 20.19.7 + fsevents: 2.3.3 + jiti: 2.4.2 + lightningcss: 1.30.1 + yaml: 2.8.0 + + vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.7)(@vitest/ui@3.2.4)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0): + dependencies: + '@types/chai': 5.2.2 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.0.4(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.2.1 + debug: 4.4.1 + expect-type: 1.2.2 + magic-string: 0.30.17 + pathe: 2.0.3 + picomatch: 4.0.2 + std-env: 3.9.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.14 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.0.4(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0) + vite-node: 3.2.4(@types/node@20.19.7)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 20.19.7 + '@vitest/ui': 3.2.4(vitest@3.2.4) + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + web-namespaces@2.0.1: {} + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + yallist@5.0.0: {} + yaml@2.8.0: {} + zwitch@2.0.4: {} diff --git a/src/api/posts.ts b/src/api/posts.ts deleted file mode 100644 index 37dfc4b..0000000 --- a/src/api/posts.ts +++ /dev/null @@ -1,34 +0,0 @@ -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/blog/[slug]/page.tsx b/src/app/blog/[slug]/page.tsx index 626142b..96c4d0a 100644 --- a/src/app/blog/[slug]/page.tsx +++ b/src/app/blog/[slug]/page.tsx @@ -1,6 +1,6 @@ import { notFound } from 'next/navigation' -import { getAllPosts } from '@/api/posts' +import { getAllPosts } from '@/entities/posts' export async function generateStaticParams() { const posts = await getAllPosts() diff --git a/src/contents/ss.mdx b/src/contents/ss.mdx index 31bcd46..aa0dee0 100644 --- a/src/contents/ss.mdx +++ b/src/contents/ss.mdx @@ -1,3 +1,10 @@ +--- +title: 'ss' +slug: 'ss' +date: '2025-07-13' +tags: ['Next.js', 'App Router', '404'] +--- + # ss asdasd diff --git "a/src/contents/\343\205\207\343\205\207.mdx" "b/src/contents/\343\205\207\343\205\207.mdx" index 15d413e..199e911 100644 --- "a/src/contents/\343\205\207\343\205\207.mdx" +++ "b/src/contents/\343\205\207\343\205\207.mdx" @@ -1,3 +1,10 @@ +--- +title: 'App Router에서 getStaticParams와 404 페이지 처리하기' +slug: 'app-router-getstaticparams-404' +date: '2025-07-14' +tags: ['Next.js', 'App Router', '404'] +--- + # App Router에서 getStaticParams와 404 페이지 처리하기 Next.js 13부터 도입된 App Router에서는 동적 라우팅과 정적 생성을 위한 새로운 방식이 도입되었습니다. 이번 글에서는 `generateStaticParams` 함수와 404 페이지 처리에 대해 알아보겠습니다. diff --git a/src/entities/entities.integration.test.ts b/src/entities/entities.integration.test.ts new file mode 100644 index 0000000..640456d --- /dev/null +++ b/src/entities/entities.integration.test.ts @@ -0,0 +1,330 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { Post } from './posts/types' +import { getAllTags, getTagGraph, getTagRelationships } from './tags' + +// Mock posts entity with simplified data +vi.mock('./posts', () => ({ + getAllPosts: vi.fn(), +})) + +const mockGetAllPosts = vi.mocked(await import('./posts')).getAllPosts + +describe('Entities Integration Tests', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Posts and Tags Integration', () => { + it('should correctly extract tags from posts and create relationships', async () => { + const mockPosts: Pick[] = [ + { + slug: 'next-js-guide', + data: { + title: 'Next.js 완벽 가이드', + date: '2025-01-01', + tags: [ + 'Next.js', + 'React', + 'SSR', + ], + }, + }, + { + slug: 'react-hooks', + data: { + title: 'React Hooks 마스터하기', + date: '2025-01-02', + tags: [ + 'React', + 'Hooks', + 'JavaScript', + ], + }, + }, + { + slug: 'typescript-tips', + data: { + title: 'TypeScript 팁과 트릭', + date: '2025-01-03', + tags: [ + 'TypeScript', + 'JavaScript', + 'Development', + ], + }, + }, + ] + + mockGetAllPosts.mockResolvedValue(mockPosts as Post[]) + + // Test tags extraction + const tags = await getAllTags() + expect(tags).toHaveLength(7) // All unique tags + + // React should be most used (appears in 2 posts) + const reactTag = tags.find((tag) => tag.name === 'React') + expect(reactTag).toEqual({ + name: 'React', + count: 2, + posts: [ + 'next-js-guide', + 'react-hooks', + ], + }) + + // JavaScript should also appear in 2 posts + const jsTag = tags.find((tag) => tag.name === 'JavaScript') + expect(jsTag).toEqual({ + name: 'JavaScript', + count: 2, + posts: [ + 'react-hooks', + 'typescript-tips', + ], + }) + }) + + it('should create proper graph relationships between tags', async () => { + const mockPosts: Pick[] = [ + { + slug: 'web-dev', + data: { + title: 'Web Development', + date: '2025-01-01', + tags: [ + 'HTML', + 'CSS', + 'JavaScript', + ], + }, + }, + { + slug: 'frontend', + data: { + title: 'Frontend Framework', + date: '2025-01-02', + tags: [ + 'JavaScript', + 'React', + 'Vue', + ], + }, + }, + ] + + mockGetAllPosts.mockResolvedValue(mockPosts as Post[]) + + const graph = await getTagGraph() + + // Should have all 5 unique tags as nodes + expect(graph.order).toBe(5) + expect(graph.hasNode('HTML')).toBe(true) + expect(graph.hasNode('CSS')).toBe(true) + expect(graph.hasNode('JavaScript')).toBe(true) + expect(graph.hasNode('React')).toBe(true) + expect(graph.hasNode('Vue')).toBe(true) + + // Check co-occurrence edges + expect(graph.hasEdge('HTML', 'CSS')).toBe(true) // Both in web-dev + expect(graph.hasEdge('HTML', 'JavaScript')).toBe(true) // Both in web-dev + expect(graph.hasEdge('CSS', 'JavaScript')).toBe(true) // Both in web-dev + expect(graph.hasEdge('JavaScript', 'React')).toBe(true) // Both in frontend + expect(graph.hasEdge('JavaScript', 'Vue')).toBe(true) // Both in frontend + expect(graph.hasEdge('React', 'Vue')).toBe(true) // Both in frontend + + // Should not have edges between non-co-occurring tags + expect(graph.hasEdge('HTML', 'React')).toBe(false) + expect(graph.hasEdge('CSS', 'Vue')).toBe(false) + + // Check JavaScript centrality (appears in both posts) + const jsAttrs = graph.getNodeAttributes('JavaScript') + expect(jsAttrs.count).toBe(2) + expect(jsAttrs.weight).toBe(1) // 2 occurrences out of 2 posts + expect(graph.degree('JavaScript')).toBe(4) // Connected to 4 other tags + }) + + it('should analyze tag relationships correctly', async () => { + const mockPosts = [ + { + slug: 'react-guide', + data: { + title: 'React Guide', + date: '2025-01-01', + tags: [ + 'React', + 'JavaScript', + 'Frontend', + ], + }, + }, + { + slug: 'vue-guide', + data: { + title: 'Vue Guide', + date: '2025-01-02', + tags: [ + 'Vue', + 'JavaScript', + 'Frontend', + ], + }, + }, + { + slug: 'js-fundamentals', + data: { + title: 'JS Fundamentals', + date: '2025-01-03', + tags: [ + 'JavaScript', + 'Programming', + ], + }, + }, + ] + + mockGetAllPosts.mockResolvedValue(mockPosts as Post[]) + + const relationships = await getTagRelationships() + + // JavaScript should have the most relationships (appears in all 3 posts) + const jsRelationship = relationships.find((r) => r.tag === 'JavaScript') + expect(jsRelationship).toBeDefined() + expect(jsRelationship?.relatedTags).toHaveLength(4) // React, Vue, Frontend, Programming + + // Frontend should be connected to React and Vue + const frontendRelationship = relationships.find( + (r) => r.tag === 'Frontend' + ) + expect(frontendRelationship).toBeDefined() + expect(frontendRelationship?.relatedTags).toHaveLength(3) // JavaScript, React, Vue + + // Programming should only be connected to JavaScript + const programmingRelationship = relationships.find( + (r) => r.tag === 'Programming' + ) + expect(programmingRelationship).toBeDefined() + expect(programmingRelationship?.relatedTags).toHaveLength(1) // Only JavaScript + expect(programmingRelationship?.relatedTags[0].name).toBe('JavaScript') + }) + + it('should handle edge cases in integration', async () => { + const mockPosts = [ + { + slug: 'no-tags', + data: { + title: 'No Tags Post', + date: '2025-01-01', + }, + }, + { + slug: 'empty-tags', + data: { + title: 'Empty Tags Post', + date: '2025-01-02', + tags: [], + }, + }, + { + slug: 'normal-post', + data: { + title: 'Normal Post', + date: '2025-01-03', + tags: [ + 'Normal', + 'Post', + ], + }, + }, + ] + + mockGetAllPosts.mockResolvedValue(mockPosts as Post[]) + + const tags = await getAllTags() + expect(tags).toHaveLength(2) // Only 'Normal' and 'Post' from the third post + + const graph = await getTagGraph() + expect(graph.order).toBe(2) // Only 2 nodes + expect(graph.size).toBe(1) // Only 1 edge (Normal-Post) + }) + }) + + describe('Real-world Data Simulation', () => { + it('should handle blog-like data structure', async () => { + const mockPosts = [ + { + slug: 'getting-started-with-react', + data: { + title: 'Getting Started with React', + date: '2025-01-01', + tags: [ + 'React', + 'JavaScript', + 'Frontend', + 'Beginner', + ], + }, + }, + { + slug: 'advanced-typescript-patterns', + data: { + title: 'Advanced TypeScript Patterns', + date: '2025-01-02', + tags: [ + 'TypeScript', + 'JavaScript', + 'Advanced', + 'Patterns', + ], + }, + }, + { + slug: 'building-with-nextjs', + data: { + title: 'Building with Next.js', + date: '2025-01-03', + tags: [ + 'Next.js', + 'React', + 'SSR', + 'Frontend', + ], + }, + }, + { + slug: 'javascript-fundamentals', + data: { + title: 'JavaScript Fundamentals', + date: '2025-01-04', + tags: [ + 'JavaScript', + 'Fundamentals', + 'Beginner', + ], + }, + }, + ] + + mockGetAllPosts.mockResolvedValue(mockPosts as Post[]) + + const tags = await getAllTags() + const graph = await getTagGraph() + const relationships = await getTagRelationships() + + // Verify comprehensive data processing + expect(tags.length).toBeGreaterThan(0) + expect(graph.order).toBeGreaterThan(0) + expect(relationships.length).toBeGreaterThan(0) + + // JavaScript should be the most connected tag + const jsNode = relationships.find((r) => r.tag === 'JavaScript') + expect(jsNode).toBeDefined() + expect(jsNode?.relatedTags.length).toBeGreaterThanOrEqual(3) + + // Verify graph structure makes sense + expect(graph.hasEdge('React', 'Frontend')).toBe(true) // Co-occur in 2 posts + expect(graph.hasEdge('JavaScript', 'Beginner')).toBe(true) // Co-occur in 2 posts + expect(graph.hasEdge('Next.js', 'React')).toBe(true) // Co-occur in 1 post + }) + }) +}) diff --git a/src/entities/posts/index.ts b/src/entities/posts/index.ts new file mode 100644 index 0000000..20be9dd --- /dev/null +++ b/src/entities/posts/index.ts @@ -0,0 +1,30 @@ +import { readdir, readFile } from 'node:fs/promises' +import { join } from 'node:path' +import matter from 'gray-matter' + +import type { Post } from './types' + +const postsDirectory = join(process.cwd(), 'src', 'contents') + +export async function getPostFileNames() { + const files = await readdir(postsDirectory) + return files.filter((file) => file.endsWith('.mdx')) +} + +export async function getPostByFileName(fileName: string) { + const fullPath = join(postsDirectory, fileName) + const fileContents = matter(await readFile(fullPath, 'utf-8')) as Post + + return Object.assign(fileContents, { + slug: fileName.replace(/\.mdx$/, ''), + }) +} + +export async function getAllPosts() { + const fileNames = await getPostFileNames() + const postPromises = fileNames.map((fileName) => getPostByFileName(fileName)) + const posts = await Promise.all(postPromises) + return posts.toSorted((post1, post2) => + new Date(post1.data.date) > new Date(post2.data.date) ? -1 : 1 + ) +} diff --git a/src/entities/posts/posts.test.ts b/src/entities/posts/posts.test.ts new file mode 100644 index 0000000..a2ab881 --- /dev/null +++ b/src/entities/posts/posts.test.ts @@ -0,0 +1,275 @@ +import { readdir, readFile } from 'node:fs/promises' +import matter from 'gray-matter' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { getAllPosts, getPostByFileName, getPostFileNames } from './index' + +// Mock dependencies +vi.mock('node:fs/promises') +vi.mock('node:path', () => ({ + join: vi.fn((...args: string[]) => args.join('/')), +})) +vi.mock('gray-matter') + +const mockReaddir = vi.mocked(readdir) +const mockReadFile = vi.mocked(readFile) +const mockMatter = vi.mocked(matter) + +describe('Posts Entity', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('getPostFileNames', () => { + it('should return only .mdx files', async () => { + mockReaddir.mockResolvedValue([ + 'post1.mdx', + 'post2.mdx', + 'README.md', + 'config.json', + 'post3.mdx', + ] as any) + + const result = await getPostFileNames() + + expect(result).toEqual([ + 'post1.mdx', + 'post2.mdx', + 'post3.mdx', + ]) + expect(mockReaddir).toHaveBeenCalledWith( + '/home/kayce/dev/blog/src/contents' + ) + }) + + it('should return empty array when no .mdx files', async () => { + mockReaddir.mockResolvedValue([ + 'README.md', + 'config.json', + ] as any) + + const result = await getPostFileNames() + + expect(result).toEqual([]) + }) + }) + + describe('getPostByFileName', () => { + it('should parse MDX file with frontmatter correctly', async () => { + const mockFileContent = `--- +title: 'Test Post' +date: 2025-01-01 +tags: ['test', 'example'] +--- + +# Test Content + +This is a test post.` + + mockReadFile.mockResolvedValue(mockFileContent) + mockMatter.mockReturnValue({ + content: '\n# Test Content\n\nThis is a test post.', + data: { + title: 'Test Post', + date: '2025-01-01', + tags: [ + 'test', + 'example', + ], + }, + } as any) + + const result = await getPostByFileName('test-post.mdx') + + expect(result).toEqual({ + content: '\n# Test Content\n\nThis is a test post.', + data: { + title: 'Test Post', + date: '2025-01-01', + tags: [ + 'test', + 'example', + ], + }, + slug: 'test-post', + }) + expect(mockReadFile).toHaveBeenCalledWith( + '/home/kayce/dev/blog/src/contents/test-post.mdx', + 'utf-8' + ) + }) + + it('should handle empty frontmatter', async () => { + const mockFileContent = `--- +--- + +# Only Content + +No frontmatter data.` + + mockReadFile.mockResolvedValue(mockFileContent) + mockMatter.mockReturnValue({ + content: '\n# Only Content\n\nNo frontmatter data.', + data: {}, + } as any) + + const result = await getPostByFileName('empty-frontmatter.mdx') + + expect(result.slug).toBe('empty-frontmatter') + expect(result.content).toContain('# Only Content') + expect(result.data).toEqual({}) + }) + + it('should handle file without frontmatter', async () => { + const mockFileContent = `# Just Content + +No frontmatter at all.` + + mockReadFile.mockResolvedValue(mockFileContent) + mockMatter.mockReturnValue({ + content: mockFileContent, + data: {}, + } as any) + + const result = await getPostByFileName('no-frontmatter.mdx') + + expect(result.slug).toBe('no-frontmatter') + expect(result.content).toBe(mockFileContent) + expect(result.data).toEqual({}) + }) + }) + + describe('getAllPosts', () => { + it('should return all posts sorted by date (newest first)', async () => { + mockReaddir.mockResolvedValue([ + 'post1.mdx', + 'post2.mdx', + 'post3.mdx', + ] as any) + + mockReadFile + .mockResolvedValueOnce('file1') + .mockResolvedValueOnce('file2') + .mockResolvedValueOnce('file3') + + mockMatter + .mockReturnValueOnce({ + content: 'Old content', + data: { + title: 'Old Post', + date: '2025-01-01', + tags: [ + 'old', + ], + }, + } as any) + .mockReturnValueOnce({ + content: 'New content', + data: { + title: 'New Post', + date: '2025-01-03', + tags: [ + 'new', + ], + }, + } as any) + .mockReturnValueOnce({ + content: 'Middle content', + data: { + title: 'Middle Post', + date: '2025-01-02', + tags: [ + 'middle', + ], + }, + } as any) + + const result = await getAllPosts() + + expect(result).toHaveLength(3) + expect(result[0].data.title).toBe('New Post') + expect(result[1].data.title).toBe('Middle Post') + expect(result[2].data.title).toBe('Old Post') + }) + + it('should handle posts with invalid date format', async () => { + mockReaddir.mockResolvedValue([ + 'post1.mdx', + 'post2.mdx', + ] as any) + + mockReadFile.mockResolvedValueOnce('file1').mockResolvedValueOnce('file2') + + mockMatter + .mockReturnValueOnce({ + content: 'Content 1', + data: { + title: 'Post 1', + date: 'invalid-date', + tags: [ + 'test', + ], + }, + } as any) + .mockReturnValueOnce({ + content: 'Content 2', + data: { + title: 'Post 2', + date: '2025-01-01', + tags: [ + 'test', + ], + }, + } as any) + + const result = await getAllPosts() + + expect(result).toHaveLength(2) + // Should handle invalid date gracefully - both posts should be returned + expect(result.map((p) => p.data.title)).toContain('Post 1') + expect(result.map((p) => p.data.title)).toContain('Post 2') + }) + + it('should handle empty directory', async () => { + mockReaddir.mockResolvedValue([] as any) + + const result = await getAllPosts() + + expect(result).toEqual([]) + }) + + it('should handle posts with same date', async () => { + mockReaddir.mockResolvedValue([ + 'post1.mdx', + 'post2.mdx', + ] as any) + + mockReadFile.mockResolvedValueOnce('file1').mockResolvedValueOnce('file2') + + mockMatter + .mockReturnValueOnce({ + content: 'Content A', + data: { + title: 'Post A', + date: '2025-01-01', + }, + } as any) + .mockReturnValueOnce({ + content: 'Content B', + data: { + title: 'Post B', + date: '2025-01-01', + }, + } as any) + + const result = await getAllPosts() + + expect(result).toHaveLength(2) + // Should maintain stable sort order + expect(result.map((p) => p.data.title)).toEqual([ + 'Post A', + 'Post B', + ]) + }) + }) +}) diff --git a/src/entities/posts/types.ts b/src/entities/posts/types.ts new file mode 100644 index 0000000..d8a9f2f --- /dev/null +++ b/src/entities/posts/types.ts @@ -0,0 +1,10 @@ +import type { GrayMatterFile } from 'gray-matter' + +export interface Post extends GrayMatterFile { + slug: string + data: { + date: string + title: string + tags: Array + } +} diff --git a/src/entities/tags/index.ts b/src/entities/tags/index.ts new file mode 100644 index 0000000..82c8ee1 --- /dev/null +++ b/src/entities/tags/index.ts @@ -0,0 +1,231 @@ +import { UndirectedGraph } from 'graphology' + +import { getAllPosts } from '../posts' + +import type { + Tag, + TagCluster, + TagEdgeAttributes, + TagGraph, + TagNodeAttributes, + TagRelationship, +} from './types' + +/** + * 모든 태그 정보를 가져옵니다. + */ +export async function getAllTags(): Promise { + const posts = await getAllPosts() + const tagMap: Record = {} + + for (const post of posts) { + const tags = post.data.tags || [] + + for (const tagName of tags) { + if (tagMap[tagName]) { + const tag = tagMap[tagName] + tag.count += 1 + tag.posts.push(post.slug) + } else { + tagMap[tagName] = { + name: tagName, + count: 1, + posts: [ + post.slug, + ], + } + } + } + } + + return Object.values(tagMap).sort((a, b) => b.count - a.count) +} + +/** + * 특정 태그의 정보를 가져옵니다. + */ +export async function getTagByName(name: string): Promise { + const tags = await getAllTags() + return tags.find((tag) => tag.name === name) || null +} + +/** + * 태그 그래프를 생성합니다. + */ +export async function getTagGraph(): Promise< + UndirectedGraph +> { + const posts = await getAllPosts() + const tags = await getAllTags() + + // graphology 인스턴스 생성 + const graph = new UndirectedGraph() + + // 노드 추가 + for (const tag of tags) { + graph.addNode(tag.name, { + name: tag.name, + count: tag.count, + weight: tag.count / posts.length, // 전체 포스트 대비 비율 + posts: tag.posts, + }) + } + + // 엣지 생성을 위한 동시 출현 관계 계산 + const cooccurrenceMap = new Map() + + for (const post of posts) { + const postTags = post.data.tags || [] + + // 같은 포스트에 있는 태그들 간의 관계 계산 + for (let i = 0; i < postTags.length; i++) { + for (let j = i + 1; j < postTags.length; j++) { + const tag1 = postTags[i] + const tag2 = postTags[j] + const key = [ + tag1, + tag2, + ] + .sort() + .join('|') + + cooccurrenceMap.set(key, (cooccurrenceMap.get(key) || 0) + 1) + } + } + } + + // 엣지 추가 + for (const [key, count] of cooccurrenceMap) { + const [source, target] = key.split('|') + + // 두 노드가 모두 존재하는 경우에만 엣지 추가 + if (graph.hasNode(source) && graph.hasNode(target)) { + graph.addEdge(source, target, { + weight: count / posts.length, // 전체 포스트 대비 동시 출현 비율 + cooccurrence: count, // 실제 동시 출현 횟수 + }) + } + } + + return graph +} + +/** + * 태그 간의 관계를 분석합니다. + */ +export async function getTagRelationships(): Promise { + const graph = await getTagGraph() + const relationships: TagRelationship[] = [] + + // 모든 노드에 대해 관계 분석 + graph.forEachNode((node) => { + const relatedTags: TagRelationship['relatedTags'] = [] + + // 해당 노드의 모든 이웃 노드 순회 + graph.forEachNeighbor(node, (neighbor) => { + const edgeAttributes = graph.getEdgeAttributes(node, neighbor) + const nodeAttributes = graph.getNodeAttributes(node) + const neighborAttributes = graph.getNodeAttributes(neighbor) + + relatedTags.push({ + name: neighbor, + cooccurrence: edgeAttributes.cooccurrence, + similarity: + edgeAttributes.weight / + Math.max(nodeAttributes.weight, neighborAttributes.weight), + }) + }) + + // 유사도 기준으로 정렬 + relatedTags.sort((a, b) => b.similarity - a.similarity) + + relationships.push({ + tag: node, + relatedTags, + }) + }) + + return relationships +} + +/** + * 태그 클러스터를 생성합니다 (graphology 기반). + */ +export async function getTagClusters(): Promise { + const graph = await getTagGraph() + const clusters: TagCluster[] = [] + const processed = new Set() + + // 각 노드의 degree centrality 계산 + const centralityMap = new Map() + graph.forEachNode((node) => { + centralityMap.set(node, graph.degree(node)) + }) + + // centrality 기준으로 정렬된 노드들 + const sortedNodes = Array.from(centralityMap.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([node]) => node) + + for (const node of sortedNodes) { + if (processed.has(node)) continue + + const cluster: TagCluster = { + id: node, + name: node, + tags: [ + node, + ], + centrality: centralityMap.get(node) || 0, + } + + // 강하게 연결된 이웃 노드들을 클러스터에 추가 + const neighbors: Array<{ + node: string + weight: number + }> = [] + + graph.forEachNeighbor(node, (neighbor) => { + if (!processed.has(neighbor)) { + const edgeAttributes = graph.getEdgeAttributes(node, neighbor) + neighbors.push({ + node: neighbor, + weight: edgeAttributes.weight, + }) + } + }) + + // 가중치 기준으로 정렬하여 상위 3개만 선택 + neighbors + .sort((a, b) => b.weight - a.weight) + .slice(0, 5) + .forEach(({ node: neighbor, weight }) => { + if (weight > 0.3) { + // 임계값 이상인 경우만 + cluster.tags.push(neighbor) + processed.add(neighbor) + } + }) + + processed.add(node) + clusters.push(cluster) + } + + return clusters +} + +/** + * 태그별 포스트 개수 통계를 가져옵니다. + */ +export async function getTagStats() { + const tags = await getAllTags() + const total = tags.reduce((sum, tag) => sum + tag.count, 0) + + return { + total: tags.length, + totalOccurrences: total, + mostUsed: tags[0], + leastUsed: tags[tags.length - 1], + average: tags.length > 0 ? total / tags.length : 0, + } +} diff --git a/src/entities/tags/tags.test.ts b/src/entities/tags/tags.test.ts new file mode 100644 index 0000000..be3d3c1 --- /dev/null +++ b/src/entities/tags/tags.test.ts @@ -0,0 +1,485 @@ +import { UndirectedGraph } from 'graphology' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { Post } from '../posts/types' + +import { + getAllTags, + getTagByName, + getTagClusters, + getTagGraph, + getTagRelationships, + getTagStats, +} from './index' + +// Mock posts entity +vi.mock('../posts', () => ({ + getAllPosts: vi.fn(), +})) + +const mockGetAllPosts = vi.mocked(await import('../posts')).getAllPosts + +describe('Tags Entity', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const mockPosts = [ + { + slug: 'post1', + data: { + title: 'Post 1', + date: '2025-01-01', + tags: [ + 'React', + 'JavaScript', + 'Web', + ], + }, + }, + { + slug: 'post2', + data: { + title: 'Post 2', + date: '2025-01-01', + tags: [ + 'React', + 'JavaScript', + 'Web', + 'TypeScript', + ], + }, + }, + { + slug: 'post3', + data: { + title: 'Post 3', + date: '2025-01-01', + tags: [ + 'React', + 'JavaScript', + 'Web', + ], + }, + }, + { + slug: 'post4', + data: { + title: 'Post 4', + date: '2025-01-02', + tags: [ + 'React', + 'TypeScript', + ], + }, + }, + { + slug: 'post5', + data: { + title: 'Post 5', + date: '2025-01-03', + tags: [ + 'JavaScript', + 'Node.js', + ], + }, + }, + ] + + describe('getAllTags', () => { + it('should return all tags with counts and post references', async () => { + mockGetAllPosts.mockResolvedValue(mockPosts as Post[]) + + const result = await getAllTags() + + expect(result).toHaveLength(5) + + // Should be sorted by count (descending) + expect(result[0]).toEqual({ + name: 'React', + count: 4, + posts: [ + 'post1', + 'post2', + 'post3', + 'post4', + ], + }) + expect(result[1]).toEqual({ + name: 'JavaScript', + count: 4, + posts: [ + 'post1', + 'post2', + 'post3', + 'post5', + ], + }) + + // Single occurrence tags + expect(result.find((tag) => tag.name === 'TypeScript')).toEqual({ + name: 'TypeScript', + count: 2, + posts: [ + 'post2', + 'post4', + ], + }) + }) + + it('should handle posts without tags', async () => { + const postsWithoutTags = [ + { + slug: 'post1', + data: { + title: 'Post 1', + date: '2025-01-01', + }, + }, + { + slug: 'post2', + data: { + title: 'Post 2', + date: '2025-01-02', + tags: [ + 'React', + ], + }, + }, + ] + + mockGetAllPosts.mockResolvedValue(postsWithoutTags as Post[]) + + const result = await getAllTags() + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + name: 'React', + count: 1, + posts: [ + 'post2', + ], + }) + }) + + it('should handle empty posts array', async () => { + mockGetAllPosts.mockResolvedValue([]) + + const result = await getAllTags() + + expect(result).toEqual([]) + }) + }) + + describe('getTagByName', () => { + it('should return specific tag by name', async () => { + mockGetAllPosts.mockResolvedValue(mockPosts as Post[]) + + const result = await getTagByName('React') + + expect(result).toEqual({ + name: 'React', + count: 4, + posts: [ + 'post1', + 'post2', + 'post3', + 'post4', + ], + }) + }) + + it('should return null for non-existent tag', async () => { + mockGetAllPosts.mockResolvedValue(mockPosts as Post[]) + + const result = await getTagByName('NonExistent') + + expect(result).toBeNull() + }) + }) + + describe('getTagGraph', () => { + it('should create a graph with nodes and edges', async () => { + mockGetAllPosts.mockResolvedValue(mockPosts as Post[]) + + const graph = await getTagGraph() + + expect(graph).toBeInstanceOf(UndirectedGraph) + + // Check nodes + expect(graph.order).toBe(5) // 5 unique tags + expect(graph.hasNode('React')).toBe(true) + expect(graph.hasNode('JavaScript')).toBe(true) + expect(graph.hasNode('TypeScript')).toBe(true) + + // Check node attributes + const reactAttrs = graph.getNodeAttributes('React') + expect(reactAttrs).toEqual({ + name: 'React', + count: 4, + weight: 4 / 5, // 2 occurrences out of 3 posts + posts: [ + 'post1', + 'post2', + 'post3', + 'post4', + ], + }) + + // Check edges (co-occurrence) + expect(graph.hasEdge('React', 'JavaScript')).toBe(true) // Both in post1 + expect(graph.hasEdge('React', 'TypeScript')).toBe(true) // Both in post2 + expect(graph.hasEdge('JavaScript', 'Node.js')).toBe(true) // Both in post3 + + // Check edge attributes + const edgeAttrs = graph.getEdgeAttributes('React', 'JavaScript') + expect(edgeAttrs).toEqual({ + weight: 3 / 5, // 3 co-occurrence out of 5 posts + cooccurrence: 3, + }) + }) + + it('should handle single tag posts', async () => { + const singleTagPosts = [ + { + slug: 'post1', + data: { + title: 'Post 1', + date: '2025-01-01', + tags: [ + 'React', + ], + }, + }, + { + slug: 'post2', + data: { + title: 'Post 2', + date: '2025-01-02', + tags: [ + 'Vue', + ], + }, + }, + ] + + mockGetAllPosts.mockResolvedValue(singleTagPosts as Post[]) + + const graph = await getTagGraph() + + expect(graph.order).toBe(2) + expect(graph.size).toBe(0) // No edges since no co-occurrences + }) + }) + + describe('getTagRelationships', () => { + it('should return relationships for all tags', async () => { + mockGetAllPosts.mockResolvedValue(mockPosts as Post[]) + + const relationships = await getTagRelationships() + + expect(relationships).toHaveLength(5) + + // Find React relationships + const reactRelationship = relationships.find((r) => r.tag === 'React') + expect(reactRelationship).toBeDefined() + expect(reactRelationship?.relatedTags).toHaveLength(3) // JavaScript, TypeScript, Web + + // Should be sorted by similarity (descending) + expect(reactRelationship?.relatedTags[0].name).toBe('JavaScript') + expect(reactRelationship?.relatedTags[0].cooccurrence).toBe(3) + expect(reactRelationship?.relatedTags[0].similarity).toBeGreaterThan(0) + }) + + it('should handle tags with no relationships', async () => { + const isolatedTagPosts = [ + { + slug: 'post1', + data: { + title: 'Post 1', + date: '2025-01-01', + tags: [ + 'React', + ], + }, + }, + ] + + mockGetAllPosts.mockResolvedValue(isolatedTagPosts as Post[]) + + const relationships = await getTagRelationships() + + expect(relationships).toHaveLength(1) + expect(relationships[0].tag).toBe('React') + expect(relationships[0].relatedTags).toHaveLength(0) + }) + }) + + describe('getTagClusters', () => { + it('should create clusters based on centrality', async () => { + mockGetAllPosts.mockResolvedValue(mockPosts as Post[]) + + const clusters = await getTagClusters() + + expect(clusters.length).toBeGreaterThan(0) + + // Should be sorted by centrality (descending) + expect(clusters[0].centrality).toBeGreaterThanOrEqual( + clusters[1]?.centrality || 0 + ) + + // Each cluster should have at least the main tag + clusters.forEach((cluster) => { + expect(cluster.tags).toContain(cluster.name) + expect(cluster.tags.length).toBeGreaterThanOrEqual(1) + }) + }) + + it('should handle single tag scenario', async () => { + const singleTagPosts = [ + { + slug: 'post1', + data: { + title: 'Post 1', + date: '2025-01-01', + tags: [ + 'React', + ], + }, + }, + ] + + mockGetAllPosts.mockResolvedValue(singleTagPosts as Post[]) + + const clusters = await getTagClusters() + + expect(clusters).toHaveLength(1) + expect(clusters[0]).toEqual({ + id: 'React', + name: 'React', + tags: [ + 'React', + ], + centrality: 0, // No connections + }) + }) + + it('should handle weak relationships in clustering', async () => { + // Create posts with weak co-occurrence relationships + const weakRelationshipPosts = [ + { + slug: 'post1', + data: { + title: 'Post 1', + date: '2025-01-01', + tags: [ + 'React', + 'JavaScript', + ], + }, + }, + { + slug: 'post2', + data: { + title: 'Post 2', + date: '2025-01-02', + tags: [ + 'React', + 'TypeScript', + ], + }, + }, + { + slug: 'post3', + data: { + title: 'Post 3', + date: '2025-01-03', + tags: [ + 'React', + 'Testing', + ], + }, + }, + { + slug: 'post4', + data: { + title: 'Post 4', + date: '2025-01-04', + tags: [ + 'React', + 'Performance', + ], + }, + }, + { + slug: 'post5', + data: { + title: 'Post 5', + date: '2025-01-05', + tags: [ + 'Vue', + ], + }, + }, + ] + + mockGetAllPosts.mockResolvedValue(weakRelationshipPosts as Post[]) + + const clusters = await getTagClusters() + + // React should be the main cluster with high centrality + const reactCluster = clusters.find((c) => c.id === 'React') + expect(reactCluster).toBeDefined() + expect(reactCluster?.centrality).toBe(4) // Connected to 4 other tags + + // Vue should be isolated with no connections + const vueCluster = clusters.find((c) => c.id === 'Vue') + expect(vueCluster).toBeDefined() + expect(vueCluster?.centrality).toBe(0) + expect(vueCluster?.tags).toEqual([ + 'Vue', + ]) + }) + }) + + describe('getTagStats', () => { + it('should return comprehensive tag statistics', async () => { + mockGetAllPosts.mockResolvedValue(mockPosts as Post[]) + + const stats = await getTagStats() + + expect(stats).toEqual({ + total: 5, // 5 unique tags + totalOccurrences: 14, // Total tag occurrences across all posts + mostUsed: { + name: 'React', + count: 4, + posts: [ + 'post1', + 'post2', + 'post3', + 'post4', + ], + }, + leastUsed: expect.objectContaining({ + count: 1, + }), + average: 14 / 5, // Average occurrences per tag + }) + }) + + it('should handle no tags', async () => { + mockGetAllPosts.mockResolvedValue([]) + + const stats = await getTagStats() + + expect(stats).toEqual({ + total: 0, + totalOccurrences: 0, + mostUsed: undefined, + leastUsed: undefined, + average: 0, + }) + }) + }) +}) diff --git a/src/entities/tags/types.ts b/src/entities/tags/types.ts new file mode 100644 index 0000000..58c6f6c --- /dev/null +++ b/src/entities/tags/types.ts @@ -0,0 +1,37 @@ +import type { UndirectedGraph } from 'graphology' + +export interface Tag { + name: string + count: number + posts: string[] // post slugs +} + +export interface TagNodeAttributes { + name: string + count: number + weight: number // 태그의 중요도 + posts: string[] // post slugs +} + +export interface TagEdgeAttributes { + weight: number // 두 태그가 함께 나타나는 빈도 + cooccurrence: number // 실제 동시 출현 횟수 +} + +export type TagGraph = UndirectedGraph + +export interface TagRelationship { + tag: string + relatedTags: Array<{ + name: string + cooccurrence: number // 함께 나타나는 횟수 + similarity: number // 유사도 점수 (0-1) + }> +} + +export interface TagCluster { + id: string + name: string + tags: string[] + centrality: number // 클러스터의 중심성 +} diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..52ac86c --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,22 @@ +// 테스트 설정 파일 +import { vi } from 'vitest' + +// Mock file system operations for testing +vi.mock('node:fs/promises', () => ({ + readdir: vi.fn(), + readFile: vi.fn(), +})) + +// Mock process.cwd() for consistent paths +vi.mock('node:path', async () => { + const actual = await vi.importActual('node:path') + return { + ...actual, + join: vi.fn((...args: string[]) => args.join('/')), + } +}) + +// Mock gray-matter for consistent parsing +vi.mock('gray-matter', () => ({ + default: vi.fn(), +})) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..cf0c2f2 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,25 @@ +import { resolve } from 'node:path' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + setupFiles: [ + './src/test/setup.ts', + ], + coverage: { + include: [ + 'src/entities/**/*.ts', + ], + exclude: [ + '**/types.ts', + ], + }, + }, + resolve: { + alias: { + '@': resolve(__dirname, './src'), + }, + }, +})