diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53a07fc3..aaa77010 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,7 +123,7 @@ jobs: run: | echo "🔒 Checking for known vulnerabilities..." npm audit --audit-level=moderate --json > audit-report.json - if jq -e '.metadata.vulnerabilities.total > 0' audit-report.json; then + if jq -e '(.metadata.vulnerabilities.moderate + .metadata.vulnerabilities.high + .metadata.vulnerabilities.critical) > 0' audit-report.json; then echo "❌ Found security vulnerabilities. Please fix them before merging." cat audit-report.json | jq '.metadata.vulnerabilities' exit 1 @@ -158,7 +158,12 @@ jobs: while IFS= read -r commit; do hash=$(echo "$commit" | cut -d' ' -f1) message=$(echo "$commit" | cut -d' ' -f2-) - + + # Skip git-generated merge commits + if echo "$message" | grep -qE "^Merge (branch|pull request|remote-tracking branch|[0-9a-f]{7,40}) "; then + continue + fi + if ! echo "$message" | grep -qE "$commit_regex"; then invalid_commits="$invalid_commits\n$hash: $message" fi diff --git a/.github/workflows/pre-commit-validation.yml b/.github/workflows/pre-commit-validation.yml index a7c11593..1f9e0232 100644 --- a/.github/workflows/pre-commit-validation.yml +++ b/.github/workflows/pre-commit-validation.yml @@ -55,7 +55,11 @@ jobs: invalid_commits="" while IFS= read -r message; do - if [ -n "$message" ] && ! echo "$message" | grep -qE "$commit_regex"; then + # Skip empty lines and git-generated merge commits + if [ -z "$message" ] || echo "$message" | grep -qE "^Merge (branch|pull request|remote-tracking branch|[0-9a-f]{7,40}) "; then + continue + fi + if ! echo "$message" | grep -qE "$commit_regex"; then invalid_commits="$invalid_commits\n$message" fi done <<< "$commits" @@ -85,7 +89,7 @@ jobs: console_log_found=false for file in $changed_files; do - if [[ "$file" =~ \.(ts|tsx|js|jsx)$ ]]; then + if [[ "$file" =~ \.(ts|tsx|js|jsx)$ ]] && [ -f "$file" ]; then if grep -q "console\.log" "$file"; then echo "❌ Found console.log in $file" console_log_found=true @@ -115,7 +119,7 @@ jobs: todo_found=false for file in $changed_files; do - if [[ "$file" =~ \.(ts|tsx|js|jsx)$ ]]; then + if [[ "$file" =~ \.(ts|tsx|js|jsx)$ ]] && [ -f "$file" ]; then if grep -q "TODO\|FIXME" "$file"; then echo "⚠️ Found TODO/FIXME in $file" todo_found=true diff --git a/app/(landing)/blog/[slug]/page.tsx b/app/(landing)/blog/[slug]/page.tsx index 6e2557c6..d7d795d8 100644 --- a/app/(landing)/blog/[slug]/page.tsx +++ b/app/(landing)/blog/[slug]/page.tsx @@ -2,117 +2,62 @@ import React from 'react'; import { Metadata } from 'next'; import { notFound } from 'next/navigation'; import BlogPostDetails from '@/components/landing-page/blog/BlogPostDetails'; -import { getBlogPost, getBlogPosts } from '@/lib/api/blog'; -import { generateBlogPostMetadata } from '@/lib/metadata'; +import { getAllBlogPosts, getBlogPostBySlug, getRelatedPosts } from '@/lib/mdx'; interface BlogPostPageProps { params: Promise<{ slug: string }>; } -interface StaticParams { - slug: string; -} - -const STATIC_GENERATION_LIMIT = 100; - -export async function generateStaticParams(): Promise { - try { - const response = await getBlogPosts({ - page: 1, - limit: STATIC_GENERATION_LIMIT, - status: 'PUBLISHED', - }); - - const data = response.data; - - if (!data || data.length === 0) { - return []; - } - - return data.map(post => ({ - slug: post.slug, - })); - } catch { - return []; - } +export async function generateStaticParams() { + return getAllBlogPosts().map(post => ({ slug: post.slug })); } export async function generateMetadata({ params, }: BlogPostPageProps): Promise { - try { - const { slug } = await params; - - if (!slug || typeof slug !== 'string') { - return getDefaultMetadata(); - } - - const post = await getBlogPost(slug); - - if (!post) { - return getNotFoundMetadata(); - } - - return generateBlogPostMetadata(post); - } catch { - return getDefaultMetadata(); + const { slug } = await params; + const post = await getBlogPostBySlug(slug); + + if (!post) { + return { + title: 'Blog Post Not Found | Boundless', + description: + 'The requested blog post could not be found. Please check the URL or browse our other posts.', + robots: { index: false, follow: true }, + }; } + + return { + title: `${post.title} | Boundless Blog`, + description: post.excerpt, + openGraph: { + title: post.title, + description: post.excerpt, + images: post.coverImage ? [{ url: post.coverImage }] : [], + type: 'article', + publishedTime: post.publishedAt, + authors: [post.author.name], + }, + twitter: { + card: 'summary_large_image', + title: post.title, + description: post.excerpt, + images: post.coverImage ? [post.coverImage] : [], + }, + }; } const BlogPostPage = async ({ params }: BlogPostPageProps) => { - try { - const { slug } = await params; - - if (!slug || typeof slug !== 'string') { - notFound(); - } - - const post = await getBlogPost(slug); - - if (!post) { - notFound(); - } - - if (!post.id || !post.title || !post.content) { - notFound(); - } - - return ; - } catch (error) { - if (error instanceof Error) { - if ( - error.message.includes('404') || - error.message.includes('not found') - ) { - notFound(); - } - } + const { slug } = await params; + const post = await getBlogPostBySlug(slug); + if (!post) { notFound(); } -}; -function getDefaultMetadata(): Metadata { - return { - title: 'Blog Post | Boundless', - description: 'Read our latest blog posts and insights.', - robots: { - index: false, - follow: true, - }, - }; -} + const related = getRelatedPosts(post.slug, post.tags, post.categories); -function getNotFoundMetadata(): Metadata { - return { - title: 'Blog Post Not Found | Boundless', - description: - 'The requested blog post could not be found. Please check the URL or browse our other posts.', - robots: { - index: false, - follow: true, - }, - }; -} + return ; +}; export default BlogPostPage; diff --git a/app/(landing)/blog/page.tsx b/app/(landing)/blog/page.tsx index 317bb67e..a4053960 100644 --- a/app/(landing)/blog/page.tsx +++ b/app/(landing)/blog/page.tsx @@ -1,77 +1,15 @@ -'use client'; - -import { useState, useEffect } from 'react'; import BlogHero from '@/components/landing-page/blog/BlogHero'; import StreamingBlogGrid from '@/components/landing-page/blog/StreamingBlogGrid'; -import { getBlogPosts } from '@/lib/api/blog'; -import { GetBlogPostsResponse } from '@/types/blog'; -import { Loader2 } from 'lucide-react'; - -const BlogsPage = () => { - const [blogData, setBlogData] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchInitialPosts = async () => { - try { - setIsLoading(true); - const response = await getBlogPosts({ - page: 1, - limit: 12, - sortBy: 'createdAt', - sortOrder: 'desc', - status: 'PUBLISHED', - }); - setBlogData(response); - } catch { - setError('Failed to load blog posts. Please try again.'); - } finally { - setIsLoading(false); - } - }; +import { getAllBlogPosts } from '@/lib/mdx'; - fetchInitialPosts(); - }, []); - - const handleLoadMore = async ( - page: number - ): Promise => { - const response = await getBlogPosts({ - page, - limit: 12, - sortBy: 'createdAt', - sortOrder: 'desc', - status: 'PUBLISHED', - }); - return response; - }; +const BlogsPage = async () => { + const posts = getAllBlogPosts(); return (
- - {isLoading ? ( -
-
- - Loading articles... -
-
- ) : error ? ( -
-

{error}

-
- ) : blogData ? ( - - ) : null} +
); diff --git a/app/api/blog/search/route.ts b/app/api/blog/search/route.ts index 3b7fb950..0ebb8459 100644 --- a/app/api/blog/search/route.ts +++ b/app/api/blog/search/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getBlogPosts } from '@/lib/api/blog'; +import { getAllBlogPosts } from '@/lib/mdx'; export async function GET(request: NextRequest) { try { @@ -8,11 +8,13 @@ export async function GET(request: NextRequest) { const page = parseInt(searchParams.get('page') || '1'); const limit = parseInt(searchParams.get('limit') || '12'); const category = searchParams.get('category'); - const tags = searchParams.get('tags')?.split(',').filter(Boolean); - - const response = await getBlogPosts({ limit: 100 }); - const allPosts = response.data; + const tags = searchParams + .get('tags') + ?.split(',') + .map(t => t.trim().toLowerCase()) + .filter(Boolean); + const allPosts = getAllBlogPosts(); let filteredPosts = allPosts; if (q.trim()) { @@ -21,21 +23,20 @@ export async function GET(request: NextRequest) { post => post.title.toLowerCase().includes(query) || post.excerpt.toLowerCase().includes(query) || - (post.tags && - post.tags.some(tag => tag.tag.name.toLowerCase().includes(query))) + post.tags.some(tag => tag.toLowerCase().includes(query)) ); } if (category) { - filteredPosts = filteredPosts.filter( - post => post.categories && post.categories.includes(category) + const normalizedCategory = category.trim().toLowerCase(); + filteredPosts = filteredPosts.filter(post => + post.categories.some(c => c.toLowerCase() === normalizedCategory) ); } if (tags && tags.length > 0) { - filteredPosts = filteredPosts.filter( - post => - post.tags && tags.some(tag => post.tags.some(t => t.tag.name === tag)) + filteredPosts = filteredPosts.filter(post => + tags.some(tag => post.tags.some(t => t.toLowerCase() === tag)) ); } diff --git a/app/globals.css b/app/globals.css index 7bddde3b..8181625a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,8 +1,31 @@ @import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap'); @import 'tailwindcss'; @import 'tw-animate-css'; +@plugin "@tailwindcss/typography"; @custom-variant dark (&:is(.dark *)); +/* Prose (Tailwind Typography) dark theme overrides for blog content */ +@layer base { + .prose { + --tw-prose-body: #d1d5db; + --tw-prose-headings: #ffffff; + --tw-prose-lead: #d1d5db; + --tw-prose-links: #a7f950; + --tw-prose-bold: #ffffff; + --tw-prose-counters: #b5b5b5; + --tw-prose-bullets: #b5b5b5; + --tw-prose-hr: #374151; + --tw-prose-quotes: #d1d5db; + --tw-prose-quote-borders: #a7f950; + --tw-prose-captions: #9ca3af; + --tw-prose-code: #a7f950; + --tw-prose-pre-code: #e5e7eb; + --tw-prose-pre-bg: #111827; + --tw-prose-th-borders: #374151; + --tw-prose-td-borders: #1f2937; + } +} + @theme { --font-inter: 'Inter', sans-serif; --color-gray-50: #f7f7f7; diff --git a/app/sitemap.ts b/app/sitemap.ts index e5db2450..9c65ded4 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,8 +1,7 @@ import { MetadataRoute } from 'next'; -import { getBlogPosts } from '@/lib/api/blog'; +import { getAllBlogPosts } from '@/lib/mdx'; import { getHackathons } from '@/lib/api/hackathons'; import { getCrowdfundingProjects } from '@/features/projects/api'; -import type { BlogPost } from '@/types/blog'; import type { Hackathon as HackathonAPI } from '@/lib/api/hackathons'; import type { Crowdfunding } from '@/features/projects/types'; @@ -15,8 +14,8 @@ const MAX_ITEMS = 100; * Includes static pages and dynamically fetched content */ export default async function sitemap(): Promise { - const [blogPosts, hackathons, crowdfundingProjects] = await Promise.all([ - fetchBlogPostsSitemap(), + const blogPosts = fetchBlogPostsSitemap(); + const [hackathons, crowdfundingProjects] = await Promise.all([ fetchHackathonsSitemap(), fetchCrowdfundingProjectsSitemap(), ]); @@ -118,39 +117,18 @@ export default async function sitemap(): Promise { } /** - * Fetches published blog posts and formats them for sitemap - * Returns empty array if fetch fails + * Reads blog posts from MDX files and formats them for sitemap */ -async function fetchBlogPostsSitemap(): Promise { +function fetchBlogPostsSitemap(): MetadataRoute.Sitemap { try { - const response = await getBlogPosts({ - status: 'PUBLISHED', - limit: MAX_ITEMS, - }); - - // Validate response - if (!response?.data || !Array.isArray(response.data)) { - return []; - } - - return response.data - .filter((post: BlogPost) => { - // Validate required fields - if (!post.slug) { - return false; - } - return true; - }) - .map((post: BlogPost) => ({ - url: `${SITE_URL}/blog/${post.slug}`, - lastModified: new Date( - post.updatedAt || post.publishedAt || new Date() - ), - changeFrequency: 'monthly' as const, - priority: 0.7, - })); + const posts = getAllBlogPosts(); + return posts.map(post => ({ + url: `${SITE_URL}/blog/${post.slug}`, + lastModified: new Date(post.publishedAt || new Date()), + changeFrequency: 'monthly' as const, + priority: 0.7, + })); } catch { - // Silently fail if fetch fails, return empty array return []; } } diff --git a/components/landing-page/blog/BlogCard.tsx b/components/landing-page/blog/BlogCard.tsx index 2caeb828..586a3d15 100644 --- a/components/landing-page/blog/BlogCard.tsx +++ b/components/landing-page/blog/BlogCard.tsx @@ -5,21 +5,18 @@ import { CardHeader, } from '@/components/ui/card'; import Image from 'next/image'; -import { BlogPost } from '@/types/blog'; +import { MdxBlogPost } from '@/lib/mdx'; import { Badge } from '@/components/ui/badge'; import { ArrowRight, Clock } from 'lucide-react'; interface BlogCardProps { - post: BlogPost; + post: MdxBlogPost; onCardClick?: (slug: string) => void; } const BlogCard = ({ post, onCardClick }: BlogCardProps) => { return ( - + {/* Image Header with 2:1 Aspect Ratio */}
diff --git a/components/landing-page/blog/BlogGrid.tsx b/components/landing-page/blog/BlogGrid.tsx index ab00de40..a5405d40 100644 --- a/components/landing-page/blog/BlogGrid.tsx +++ b/components/landing-page/blog/BlogGrid.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useState, useCallback, useMemo } from 'react'; -import { BlogPost } from '@/types/blog'; +import { MdxBlogPost } from '@/lib/mdx'; import BlogCard from './BlogCard'; import { Search, Loader2 } from 'lucide-react'; import { Input } from '@/components/ui/input'; @@ -16,7 +16,7 @@ import { import { cn } from '@/lib/utils'; interface BlogGridProps { - posts: BlogPost[]; + posts: MdxBlogPost[]; showLoadMore?: boolean; maxPosts?: number; totalPosts?: number; @@ -29,7 +29,7 @@ const BlogGrid: React.FC = ({ maxPosts, initialPage = 1, }) => { - const [allPosts, setAllPosts] = useState(posts); + const [allPosts, setAllPosts] = useState(posts); const [currentPage, setCurrentPage] = useState(initialPage); const [visiblePosts, setVisiblePosts] = useState(maxPosts || 12); const [selectedCategories, setSelectedCategories] = useState([]); @@ -64,16 +64,15 @@ const BlogGrid: React.FC = ({ post => post.title.toLowerCase().includes(query) || post.excerpt.toLowerCase().includes(query) || - (post.tags && - post.tags.some(tag => tag.tag.name.toLowerCase().includes(query))) + post.tags.some(tag => tag.toLowerCase().includes(query)) ); } // Sort posts if (sortOrder) { filtered = [...filtered].sort((a, b) => { - const dateA = new Date(a.createdAt).getTime(); - const dateB = new Date(b.createdAt).getTime(); + const dateA = new Date(a.publishedAt).getTime(); + const dateB = new Date(b.publishedAt).getTime(); return sortOrder === 'Latest' ? dateB - dateA : dateA - dateB; }); } @@ -145,11 +144,8 @@ const BlogGrid: React.FC = ({ }; const handleCardClick = useCallback((slug: string) => { + void slug; setIsNavigating(true); - // The navigation will be handled by Next.js Link, but we show loading state - // The loading state will be cleared when the page actually navigates - // eslint-disable-next-line no-console - console.log(`Navigating to blog post: ${slug}`); setTimeout(() => { setIsNavigating(false); }, 2000); // Fallback timeout @@ -311,7 +307,7 @@ const BlogGrid: React.FC = ({ {displayPosts.length > 0 ? (
{displayPosts.map(post => ( -
+
))} diff --git a/components/landing-page/blog/BlogPostDetails.tsx b/components/landing-page/blog/BlogPostDetails.tsx index d538c7db..e7890a9e 100644 --- a/components/landing-page/blog/BlogPostDetails.tsx +++ b/components/landing-page/blog/BlogPostDetails.tsx @@ -1,52 +1,29 @@ 'use client'; -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import Image from 'next/image'; import { Tag, BookOpen, Check } from 'lucide-react'; -import { BlogPost } from '@/types/blog'; +import { MdxBlogPost } from '@/lib/mdx'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; -import { useMarkdown } from '@/hooks/use-markdown'; import BlogCard from './BlogCard'; import AuthLoadingState from '@/components/auth/AuthLoadingState'; -import { getRelatedPosts } from '@/lib/api/blog'; +import { useRouter } from 'next/navigation'; +import { useTransition, useCallback } from 'react'; interface BlogPostDetailsProps { - post: BlogPost; + post: MdxBlogPost & { content: React.ReactElement }; + relatedPosts: MdxBlogPost[]; } -const BlogPostDetails: React.FC = ({ post }) => { - const { loading, error, styledContent } = useMarkdown(post.content, { - breaks: true, - gfm: true, - pedantic: true, - loadingDelay: 100, - }); - - const [relatedPosts, setRelatedPosts] = useState([]); +const BlogPostDetails: React.FC = ({ + post, + relatedPosts, +}) => { const [copiedStates, setCopiedStates] = useState>({}); - const [isLoadingRelated, setIsLoadingRelated] = useState(true); const [isNavigating, setIsNavigating] = useState(false); - const [relatedPostsError, setRelatedPostsError] = useState( - null - ); - - useEffect(() => { - const fetchRelatedPosts = async () => { - try { - setIsLoadingRelated(true); - setRelatedPostsError(null); - const related = await getRelatedPosts(post.id); - setRelatedPosts(related.posts); - } catch { - setRelatedPostsError('Failed to load related posts'); - setRelatedPosts([]); - } finally { - setIsLoadingRelated(false); - } - }; - fetchRelatedPosts(); - }, [post.slug]); + const [isPending, startTransition] = useTransition(); + const router = useRouter(); const formatDate = (dateString: string) => { const date = new Date(dateString); @@ -104,19 +81,21 @@ const BlogPostDetails: React.FC = ({ post }) => { } }; - const handleRelatedPostClick = (slug: string) => { - setIsNavigating(true); - // The navigation will be handled by Next.js Link, but we show loading state - // eslint-disable-next-line no-console - console.log(`Navigating to related post: ${slug}`); - setTimeout(() => { - setIsNavigating(false); - }, 2000); // Fallback timeout - }; + const handleRelatedPostClick = useCallback( + (slug: string) => { + setIsNavigating(true); + startTransition(() => { + router.push(`/blog/${slug}`); + }); + }, + [router] + ); return ( <> - {isNavigating && } + {(isNavigating || isPending) && ( + + )}
@@ -139,7 +118,7 @@ const BlogPostDetails: React.FC = ({ post }) => {
- {formatDate(post.createdAt)} + {formatDate(post.publishedAt)}
@@ -168,22 +147,8 @@ const BlogPostDetails: React.FC = ({ post }) => {
-
- {loading ? ( -
-
-
- Loading content... -
-
- ) : error ? ( -
-

Error loading content:

-

{error}

-
- ) : ( - styledContent - )} +
+ {post.content}
@@ -193,12 +158,12 @@ const BlogPostDetails: React.FC = ({ post }) => { {post.tags?.map(tag => ( - {tag.tag.name} + {tag} ))}
@@ -311,22 +276,7 @@ const BlogPostDetails: React.FC = ({ post }) => {

Related Articles

- {isLoadingRelated ? ( -
- {[...Array(3)].map((_, index) => ( -
-
-
-
-
- ))} -
- ) : relatedPostsError ? ( -
- -

{relatedPostsError}

-
- ) : relatedPosts && relatedPosts.length > 0 ? ( + {relatedPosts.length > 0 ? (
{relatedPosts.map(relatedPost => ( { - try { - const response = await getBlogPosts({ - page: 1, - limit: 6, - sortBy: 'createdAt', - sortOrder: 'desc', - status: 'PUBLISHED', - }); - - return ; - } catch { - return ; - } +const BlogSection = () => { + const posts = getAllBlogPosts().slice(0, 6); + return ; }; export default BlogSection; diff --git a/components/landing-page/blog/BlogSectionClient.tsx b/components/landing-page/blog/BlogSectionClient.tsx index ccb8fac8..53ec3235 100644 --- a/components/landing-page/blog/BlogSectionClient.tsx +++ b/components/landing-page/blog/BlogSectionClient.tsx @@ -6,10 +6,10 @@ import { useRouter } from 'next/navigation'; import { useCallback, useState, useTransition } from 'react'; import AuthLoadingState from '@/components/auth/AuthLoadingState'; import BlogCard from './BlogCard'; -import { BlogPost } from '@/types/blog'; +import { MdxBlogPost } from '@/lib/mdx'; interface BlogSectionClientProps { - posts: BlogPost[]; + posts: MdxBlogPost[]; } const BlogSectionClient = ({ posts }: BlogSectionClientProps) => { @@ -71,7 +71,7 @@ const BlogSectionClient = ({ posts }: BlogSectionClientProps) => { aria-label='Blog posts grid' > {posts.slice(0, 6).map(blog => ( -
+
))} diff --git a/components/landing-page/blog/MdxComponents.tsx b/components/landing-page/blog/MdxComponents.tsx new file mode 100644 index 00000000..de28f97a --- /dev/null +++ b/components/landing-page/blog/MdxComponents.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { Badge } from '@/components/ui/badge'; + +// --------------------------------------------------------------------------- +// Code block with syntax highlighting via

+// ---------------------------------------------------------------------------
+function MdxCode({
+  children,
+  className,
+  ...props
+}: React.ComponentProps<'code'>) {
+  // Inline code (no className) vs fenced code block (has language-* className)
+  const isBlock = !!className;
+  if (!isBlock) {
+    return (
+      
+        {children}
+      
+    );
+  }
+  return (
+    
+      {children}
+    
+  );
+}
+
+function MdxPre({ children, ...props }: React.ComponentProps<'pre'>) {
+  return (
+    
+      {children}
+    
+ ); +} + +// --------------------------------------------------------------------------- +// Table +// --------------------------------------------------------------------------- +function MdxTable({ children, ...props }: React.ComponentProps<'table'>) { + return ( +
+ + {children} +
+
+ ); +} + +function MdxThead({ children, ...props }: React.ComponentProps<'thead'>) { + return ( + + {children} + + ); +} + +function MdxTh({ children, ...props }: React.ComponentProps<'th'>) { + return ( + + {children} + + ); +} + +function MdxTd({ children, ...props }: React.ComponentProps<'td'>) { + return ( + + {children} + + ); +} + +function MdxTr({ children, ...props }: React.ComponentProps<'tr'>) { + return ( + + {children} + + ); +} + +// --------------------------------------------------------------------------- +// Mermaid diagram — renders the raw text inside a styled container. +// Full client-side rendering would require a 'use client' wrapper + mermaid.js; +// for now we render a readable fallback with the diagram source. +// --------------------------------------------------------------------------- +function Mermaid({ children }: { children?: React.ReactNode }) { + return ( +
+

+ Diagram +

+
+        {children}
+      
+
+ ); +} + +// --------------------------------------------------------------------------- +// Exported component map — passed to compileMDX +// --------------------------------------------------------------------------- +export const mdxComponents = { + // HTML element overrides + pre: MdxPre, + code: MdxCode, + table: MdxTable, + thead: MdxThead, + th: MdxTh, + td: MdxTd, + tr: MdxTr, + // Named components usable inside .mdx files as / + Badge, + Mermaid, +}; diff --git a/components/landing-page/blog/StreamingBlogGrid.tsx b/components/landing-page/blog/StreamingBlogGrid.tsx index 1ba8f744..456abd0b 100644 --- a/components/landing-page/blog/StreamingBlogGrid.tsx +++ b/components/landing-page/blog/StreamingBlogGrid.tsx @@ -1,10 +1,10 @@ 'use client'; import React, { useState, useCallback, useMemo, useTransition } from 'react'; -import { BlogPost, GetBlogPostsResponse } from '@/types/blog'; +import { MdxBlogPost } from '@/lib/mdx'; import { useRouter } from 'next/navigation'; import BlogCard from './BlogCard'; -import { Search, Loader2 } from 'lucide-react'; +import { Search } from 'lucide-react'; import { Input } from '@/components/ui/input'; import AuthLoadingState from '@/components/auth/AuthLoadingState'; import { @@ -16,29 +16,21 @@ import { } from '@/components/ui/dropdown-menu'; import { cn } from '@/lib/utils'; +const PAGE_SIZE = 12; + interface StreamingBlogGridProps { - initialPosts: BlogPost[]; - hasMore: boolean; - initialPage: number; - totalPages: number; - onLoadMore: (page: number) => Promise; + initialPosts: MdxBlogPost[]; } const StreamingBlogGrid: React.FC = ({ initialPosts, - hasMore: initialHasMore, - initialPage, - onLoadMore, }) => { - const [allPosts, setAllPosts] = useState(initialPosts); - const [currentPage, setCurrentPage] = useState(initialPage); - const [hasMore, setHasMore] = useState(initialHasMore); + const [allPosts] = useState(initialPosts); + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); const [selectedCategories, setSelectedCategories] = useState([]); const [sortOrder, setSortOrder] = useState<'Latest' | 'Oldest' | ''>(''); const [searchQuery, setSearchQuery] = useState(''); - const [isLoading, setIsLoading] = useState(false); const [isNavigating, setIsNavigating] = useState(false); - const [error, setError] = useState(null); const [isPending, startTransition] = useTransition(); const router = useRouter(); @@ -65,15 +57,14 @@ const StreamingBlogGrid: React.FC = ({ post => post.title.toLowerCase().includes(query) || post.excerpt.toLowerCase().includes(query) || - (post.tags && - post.tags.some(tag => tag.tag.name.toLowerCase().includes(query))) + post.tags.some(tag => tag.toLowerCase().includes(query)) ); } if (sortOrder) { filtered = [...filtered].sort((a, b) => { - const dateA = new Date(a.coverImage).getTime(); - const dateB = new Date(b.createdAt).getTime(); + const dateA = new Date(a.publishedAt).getTime(); + const dateB = new Date(b.publishedAt).getTime(); return sortOrder === 'Latest' ? dateB - dateA : dateA - dateB; }); } @@ -81,31 +72,17 @@ const StreamingBlogGrid: React.FC = ({ return filtered; }, [allPosts, selectedCategories, searchQuery, sortOrder]); - const displayPosts = filteredPosts; - const hasMorePostsToShow = - hasMore && !searchQuery && selectedCategories.length === 0; + const isFiltering = !!searchQuery || selectedCategories.length > 0; + const displayPosts = isFiltering + ? filteredPosts + : filteredPosts.slice(0, visibleCount); + const hasMoreToShow = !isFiltering && visibleCount < filteredPosts.length; const sortOptions: Array<'Latest' | 'Oldest'> = ['Latest', 'Oldest']; - const handleLoadMore = useCallback(async () => { - if (isLoading || !hasMore) return; - - setIsLoading(true); - setError(null); - - try { - const nextPage = currentPage + 1; - const response = await onLoadMore(nextPage); - - setAllPosts(prev => [...prev, ...response.data]); - setCurrentPage(nextPage); - setHasMore(response.hasMore); - } catch { - setError('Failed to load more posts. Please try again.'); - } finally { - setIsLoading(false); - } - }, [isLoading, hasMore, currentPage, onLoadMore]); + const handleShowMore = useCallback(() => { + setVisibleCount(prev => prev + PAGE_SIZE); + }, []); const handleCategoryChange = (category: string) => { setSelectedCategories(prev => @@ -284,16 +261,10 @@ const StreamingBlogGrid: React.FC = ({
- {error && ( -
- {error} -
- )} - {displayPosts.length > 0 ? (
{displayPosts.map(post => ( -
+
))} @@ -316,10 +287,10 @@ const StreamingBlogGrid: React.FC = ({
)} - {hasMorePostsToShow && !isLoading && ( + {hasMoreToShow && (
)} - {isLoading && ( -
-
- - Loading more posts... -
-
- )} - - {!hasMore && filteredPosts.length > 0 && !isLoading && ( + {!hasMoreToShow && !isFiltering && filteredPosts.length > 0 && (
-

You've reached the end of the blog posts!

+

You've reached the end of the blog posts!

)}
diff --git a/content/blog/building-on-stellar.mdx b/content/blog/building-on-stellar.mdx new file mode 100644 index 00000000..47e5412e --- /dev/null +++ b/content/blog/building-on-stellar.mdx @@ -0,0 +1,99 @@ +--- +title: 'Why We Built Boundless on the Stellar Network' +excerpt: "When choosing a blockchain for a decentralized crowdfunding platform, we evaluated dozens of options. Here's why Stellar's combination of speed, cost, and Soroban smart contracts made it the clear winner." +coverImage: 'https://images.unsplash.com/photo-1639762681057-408e52192e55?w=800&auto=format&fit=crop' +publishedAt: '2025-02-20' +author: + name: 'Priya Chen' + image: 'https://i.pravatar.cc/150?img=47' +categories: ['Technology', 'Web3'] +tags: ['stellar', 'soroban', 'blockchain', 'tech'] +readingTime: 5 +isFeatured: true +--- + +# Why We Built Boundless on the Stellar Network + +Choosing the right blockchain for a platform like Boundless wasn't a decision we made lightly. We needed something that could handle real-world payment flows, support complex smart contract logic, and remain accessible to users around the world — including those in emerging markets. + +After evaluating over a dozen networks, we chose **Stellar**. Here's why. + +## The Core Requirements + +Our platform needed a blockchain that could: + +- Process thousands of transactions per day with **sub-cent fees** +- Support **programmable smart contracts** for escrow logic +- Achieve **fast finality** (no waiting 10+ minutes for confirmations) +- Work with **stablecoins** for predictable pricing +- Support **multi-wallet ecosystems** for user choice + +Stellar checks every box. + +## Speed and Cost + +Stellar settles transactions in **3–5 seconds** with fees of roughly **0.00001 XLM** (fractions of a cent). Compare this to Ethereum, where gas fees can spike to tens of dollars during peak usage. + +For a crowdfunding platform where backers might contribute small amounts frequently, high fees would be a deal-breaker. Stellar makes micro-contributions economically viable. + +## Soroban: Stellar's Smart Contract Platform + +[Soroban](https://stellar.org/soroban) is Stellar's Rust-based smart contract platform. It gave us everything we needed for Boundless escrow contracts: + +```rust +// Simplified escrow logic +pub fn release_milestone( + env: Env, + campaign_id: u64, + milestone_index: u32, +) -> Result<(), Error> { + let campaign = env.storage().get::(&campaign_id)?; + require_milestone_approved(&env, &campaign, milestone_index)?; + transfer_funds(&env, &campaign, milestone_index) +} +``` + +Soroban contracts are: + +- **Deterministic** — same input always produces same output +- **Gas-efficient** — lower costs than comparable EVM contracts +- **Developer-friendly** — excellent Rust SDK with strong type safety +- **Auditable** — open-source contracts anyone can verify + +## Trustless Work Integration + +We partnered with [Trustless Work](https://trustlesswork.com) to leverage their battle-tested escrow infrastructure. This let us focus on the product experience rather than reinventing contract security from scratch. + +## Global Accessibility + +Stellar was explicitly designed for **cross-border payments**. It supports: + +- Native USD and other stablecoin anchors (USDC via Circle) +- Fiat on/off ramps in 180+ countries +- Compliance with international payment standards + +This means a creator in Nigeria and a backer in Japan can transact on Boundless with the same seamless experience. + +## The Multi-Wallet Ecosystem + +Stellar has a rich wallet ecosystem. Boundless supports: + +- **Freighter** (browser extension, most popular) +- **Lobstr** (mobile-first) +- **xBull** (advanced users) +- **Albedo** (web-based) +- And more via our wallet kit integration + +## What's Next + +We're exploring Stellar's upcoming features including: + +- **Stellar Asset Contracts (SAC)** for native token support +- **Multi-sig enhancements** for DAO treasury management +- **Improved DEX integration** for instant currency conversion on contributions + +Stellar's roadmap aligns perfectly with where Boundless is headed. + +--- + +Want to learn more about how Boundless uses Stellar under the hood? Check out our [technical documentation](/docs) or join our [community Discord](https://discord.gg/boundless). diff --git a/content/blog/milestone-based-funding.mdx b/content/blog/milestone-based-funding.mdx new file mode 100644 index 00000000..85315902 --- /dev/null +++ b/content/blog/milestone-based-funding.mdx @@ -0,0 +1,81 @@ +--- +title: 'How Milestone-Based Funding Protects Backers and Motivates Creators' +excerpt: 'Milestone-based funding is the cornerstone of the Boundless platform. Discover how smart contract escrow and milestone verification create a trustless funding environment that benefits everyone.' +coverImage: 'https://images.unsplash.com/photo-1553729459-efe14ef6055d?w=800&auto=format&fit=crop' +publishedAt: '2025-02-01' +author: + name: 'Alex Rivera' + image: 'https://i.pravatar.cc/150?img=12' +categories: ['Platform', 'Education'] +tags: ['milestones', 'escrow', 'smart-contracts', 'backer-protection'] +readingTime: 6 +isFeatured: false +--- + +# How Milestone-Based Funding Protects Backers and Motivates Creators + +One of the biggest pain points in crowdfunding is **trust**. Backers worry their money will disappear. Creators worry about delivering under pressure without adequate resources. Boundless solves both problems with milestone-based funding. + +## The Problem with Traditional Crowdfunding + +On platforms like Kickstarter or GoFundMe: + +1. Creators receive **all funds upfront** with no accountability mechanism +2. Backers have **no recourse** if a project underdelivers +3. There is **no on-chain record** — disputes are resolved by a centralized company + +This creates misaligned incentives. Creators can disappear after receiving funds, and backers lose trust in the entire ecosystem. + +## Enter Milestone-Based Funding + +With Boundless, funding works differently: + +### 1. Campaign Setup + +When a creator launches a campaign, they define **milestones** — specific, verifiable goals with associated funding amounts. + +For example, a software project might have: + +| Milestone | Funding | Deliverable | +| --------- | ------- | -------------- | +| Prototype | $5,000 | Working demo | +| Beta | $15,000 | 100 beta users | +| Launch | $30,000 | Public release | + +### 2. Escrow Lock + +When backers contribute, their funds go into a **Soroban smart contract escrow** — not into the creator's wallet. The funds sit in the contract until milestones are met. + +### 3. Milestone Submission + +When a creator completes a milestone, they submit evidence (GitHub commits, demo videos, metrics) directly on-chain. + +### 4. Verification & Release + +The community or designated reviewers evaluate the submission. Upon approval, the escrow releases the corresponding tranche to the creator. + +### 5. Refund Protection + +If a creator fails to hit milestones or abandons a project, backers can trigger a refund through the smart contract — no intermediary needed. + +## Benefits for Creators + +- **Structured funding** helps with financial planning +- **Community accountability** actually increases project credibility +- **Milestone approvals** serve as public proof-of-progress +- **Reduced overhead** — no need to manage backer relations manually + +## Benefits for Backers + +- **No trust required** — the smart contract enforces the agreement +- **Transparent progress** — all milestones and submissions are public +- **Refund mechanism** — built-in protection if things go wrong +- **Community voice** — participate in milestone verification + +## Real-World Impact + +This model has already been proven in grants and DAOs. Boundless brings the same rigor to open crowdfunding, making decentralized project funding a viable alternative to traditional venture capital. + +--- + +Ready to launch your campaign with milestone-based funding? [Create your project](/projects) on Boundless today. diff --git a/content/blog/welcome-to-boundless.mdx b/content/blog/welcome-to-boundless.mdx new file mode 100644 index 00000000..e7117f8a --- /dev/null +++ b/content/blog/welcome-to-boundless.mdx @@ -0,0 +1,61 @@ +--- +title: 'Welcome to Boundless' +excerpt: "Boundless is a decentralized crowdfunding and grants platform built on the Stellar blockchain. Learn how we're empowering creators, innovators, and communities to fund their visions with transparency and security." +coverImage: 'https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=800&auto=format&fit=crop' +publishedAt: '2025-01-15' +author: + name: 'Boundless Team' + image: 'https://i.pravatar.cc/150?img=1' +categories: ['Platform', 'Web3'] +tags: ['intro', 'stellar', 'crowdfunding', 'blockchain'] +readingTime: 4 +isFeatured: true +--- + +# Welcome to Boundless + +We're thrilled to introduce **Boundless** — a decentralized crowdfunding and grants platform built on the [Stellar](https://stellar.org) blockchain. Our mission is simple: empower creators, builders, and communities to fund their visions with full transparency and zero middlemen. + +## Why Boundless? + +Traditional crowdfunding platforms take hefty fees, lock funds in centralized accounts, and leave backers with little recourse if a project fails to deliver. Boundless changes that by: + +- **Smart contract escrow** — Funds are held in Soroban smart contracts and released only when milestones are verified +- **Milestone-based releases** — Creators receive funding in tranches as they hit agreed-upon goals +- **Community governance** — Backers and DAO members vote on milestone approvals +- **Full transparency** — Every transaction is recorded on the Stellar blockchain + +## Who Is Boundless For? + +### Creators & Builders + +Launch your project with confidence. Whether you're building a Web3 app, creating art, or starting a social enterprise, Boundless gives you the tools to fundraise with credibility. + +### Backers & Investors + +Back projects you believe in knowing your funds are protected by smart contracts. If a project doesn't deliver, built-in refund mechanisms protect your investment. + +### Grant Seekers + +Apply for grants from organizations and DAOs with structured milestone-based disbursements. + +### Hackathon Organizers + +Run world-class hackathons with automated prize distribution, team management, and transparent judging. + +## Built on Stellar + +We chose Stellar for its: + +- **Low fees** — Transactions cost a fraction of a cent +- **Fast finality** — 3-5 second settlement times +- **Soroban smart contracts** — Powerful, developer-friendly contract platform +- **Global accessibility** — Works in any country with internet access + +## Get Started + +Ready to launch your project or back something you believe in? Create your account today and join the growing Boundless community. + +> "The future belongs to those who build it — Boundless gives you the tools to build it together." + +We can't wait to see what you create. diff --git a/lib/api/blog.ts b/lib/api/blog.ts deleted file mode 100644 index fe19f628..00000000 --- a/lib/api/blog.ts +++ /dev/null @@ -1,299 +0,0 @@ -import api from './api'; -// import { -// getRelatedPosts as getRelatedPostsData, -// BlogPost as DataBlogPost, -// } from '@/lib/data/blog'; -import { - BlogPost, - GetBlogPostsRequest, - GetBlogPostsResponse, - GetRelatedPostsResponse, - CreateBlogPostRequest, - UpdateBlogPostRequest, - CreateBlogPostResponse, - UpdateBlogPostResponse, - DeleteBlogPostResponse, - BlogApiError, -} from '@/types/blog'; - -interface ApiResponse { - success: boolean; - data: T; - message?: string; - timestamp: string; - path: string; -} - -/** - * Convert data layer BlogPost to API layer BlogPost - */ - -/** - * Get paginated blog posts with filtering and sorting - */ -export const getBlogPosts = async ( - params: GetBlogPostsRequest = {} -): Promise => { - try { - const { - authorId, - organizationId, - status, - search, - tags, - categories, - isFeatured, - includePinned, - page = 1, - limit = 20, - sortBy = 'createdAt', - sortOrder = 'desc', - } = params; - - const queryParams = new URLSearchParams({ - page: page.toString(), - limit: limit.toString(), - sortBy, - sortOrder, - }); - - if (authorId) { - queryParams.append('authorId', authorId); - } - - if (organizationId) { - queryParams.append('organizationId', organizationId); - } - - if (status) { - queryParams.append('status', status); - } - - if (search) { - queryParams.append('search', search); - } - - if (tags) { - queryParams.append('tags', tags); - } - - if (categories) { - queryParams.append('categories', categories); - } - - if (isFeatured !== undefined) { - queryParams.append('isFeatured', isFeatured.toString()); - } - - if (includePinned !== undefined) { - queryParams.append('includePinned', includePinned.toString()); - } - - const response = await api.get>( - `/blog-posts?${queryParams.toString()}` - ); - - return response.data.data; - } catch (error) { - throw new Error( - `Failed to fetch blog posts: ${ - error instanceof Error ? error.message : 'Unknown error' - }` - ); - } -}; - -/** - * Get a single blog post by slug - */ -export const getBlogPost = async (slug: string): Promise => { - try { - const response = await api.get>( - `/blog-posts/slug/${slug}` - ); - return response.data.data; - } catch (error) { - console.error('Error in getBlogPost:', error); - if (error instanceof Error && error.message.includes('404')) { - throw new Error(`Blog post with slug "${slug}" not found`); - } - throw new Error( - `Failed to fetch blog post: ${ - error instanceof Error ? error.message : 'Unknown error' - }` - ); - } -}; - -/** - * Get a single blog post by ID - */ -export const getBlogPostById = async (id: string): Promise => { - try { - const response = await api.get>( - `/blog-posts/id/${id}` - ); - return response.data.data; - } catch (error) { - console.error('Error in getBlogPostById:', error); - if (error instanceof Error && error.message.includes('404')) { - throw new Error(`Blog post with id "${id}" not found`); - } - throw new Error( - `Failed to fetch blog post: ${ - error instanceof Error ? error.message : 'Unknown error' - }` - ); - } -}; - -/** - * Get related blog posts - */ -export const getRelatedPosts = async ( - id: string, - limit: number = 5 -): Promise => { - try { - const response = await api.get>( - `/blog-posts/${id}/related?limit=${limit}` - ); - return response.data.data; - } catch (error) { - throw new Error( - `Failed to fetch related posts: ${ - error instanceof Error ? error.message : 'Unknown error' - }` - ); - } -}; - -/** - * Create a new blog post - */ -export const createBlogPost = async ( - data: CreateBlogPostRequest -): Promise => { - try { - const response = await api.post>( - '/blog-posts', - data - ); - return response.data.data; - } catch (error) { - throw new Error( - `Failed to create blog post: ${ - error instanceof Error ? error.message : 'Unknown error' - }` - ); - } -}; - -/** - * Update an existing blog post - */ -export const updateBlogPost = async ( - id: string, - data: UpdateBlogPostRequest -): Promise => { - try { - const response = await api.put>( - `/blog-posts/${id}`, - data - ); - return response.data.data; - } catch (error) { - throw new Error( - `Failed to update blog post: ${ - error instanceof Error ? error.message : 'Unknown error' - }` - ); - } -}; - -/** - * Delete a blog post - */ -export const deleteBlogPost = async ( - id: string -): Promise => { - try { - const response = await api.delete>( - `/blog-posts/${id}` - ); - return response.data.data; - } catch (error) { - throw new Error( - `Failed to delete blog post: ${ - error instanceof Error ? error.message : 'Unknown error' - }` - ); - } -}; - -/** - * Utility function to build query parameters for blog requests - */ -export const buildBlogQueryParams = ( - params: Record -): string => { - const queryParams = new URLSearchParams(); - - Object.entries(params).forEach(([key, value]) => { - if (value !== undefined && value !== null && value !== '') { - if (Array.isArray(value)) { - queryParams.append(key, value.join(',')); - } else { - queryParams.append(key, String(value)); - } - } - }); - - return queryParams.toString(); -}; - -/** - * Utility function to validate blog post slug - */ -export const validateBlogSlug = (slug: string): boolean => { - const slugRegex = /^[a-z0-9-]+$/; - return slugRegex.test(slug) && slug.length > 0 && slug.length <= 100; -}; - -/** - * Utility function to sanitize search query - */ -export const sanitizeSearchQuery = (query: string): string => { - return query.trim().replace(/[<>]/g, '').substring(0, 100); -}; - -/** - * Error handling utilities - */ -export const handleBlogApiError = (error: unknown): BlogApiError => { - if (error instanceof Error) { - return { - message: error.message, - status: 500, - code: 'INTERNAL_ERROR', - }; - } - - return { - message: 'An unknown error occurred', - status: 500, - code: 'UNKNOWN_ERROR', - }; -}; - -/** - * Check if an error is a blog API error - */ -export const isBlogApiError = (error: unknown): error is BlogApiError => { - return ( - typeof error === 'object' && - error !== null && - 'message' in error && - 'status' in error - ); -}; diff --git a/lib/mdx.ts b/lib/mdx.ts new file mode 100644 index 00000000..0083fed7 --- /dev/null +++ b/lib/mdx.ts @@ -0,0 +1,131 @@ +import fs from 'fs'; +import path from 'path'; +import matter from 'gray-matter'; +import { compileMDX } from 'next-mdx-remote/rsc'; +import type { ReactElement } from 'react'; +import { mdxComponents } from '@/components/landing-page/blog/MdxComponents'; + +const BLOG_DIR = path.join(process.cwd(), 'content', 'blog'); + +export interface MdxBlogPost { + slug: string; + title: string; + excerpt: string; + coverImage: string; + publishedAt: string; + author: { + name: string; + image: string; + }; + categories: string[]; + tags: string[]; + readingTime: number; + isFeatured?: boolean; +} + +export interface MdxBlogPostWithContent extends MdxBlogPost { + content: ReactElement; +} + +function parseFrontmatter(slug: string): MdxBlogPost { + const filePath = path.join(BLOG_DIR, `${slug}.mdx`); + const raw = fs.readFileSync(filePath, 'utf8'); + const { data } = matter(raw); + + return { + slug, + title: String(data.title ?? ''), + excerpt: String(data.excerpt ?? ''), + coverImage: String(data.coverImage ?? ''), + publishedAt: String(data.publishedAt ?? ''), + author: { + name: String(data.author?.name ?? ''), + image: String(data.author?.image ?? ''), + }, + categories: Array.isArray(data.categories) + ? data.categories.map((c: unknown) => String(c)) + : [], + tags: Array.isArray(data.tags) + ? data.tags.map((t: unknown) => String(t)) + : [], + readingTime: typeof data.readingTime === 'number' ? data.readingTime : 0, + isFeatured: data.isFeatured === true, + }; +} + +export function getAllBlogPosts(): MdxBlogPost[] { + if (!fs.existsSync(BLOG_DIR)) return []; + + const files = fs.readdirSync(BLOG_DIR).filter(f => f.endsWith('.mdx')); + + const posts = files.map(file => { + const slug = file.replace(/\.mdx$/, ''); + return parseFrontmatter(slug); + }); + + return posts.sort((a, b) => { + const ta = a.publishedAt ? new Date(a.publishedAt).getTime() : 0; + const tb = b.publishedAt ? new Date(b.publishedAt).getTime() : 0; + return (isNaN(tb) ? 0 : tb) - (isNaN(ta) ? 0 : ta); + }); +} + +export async function getBlogPostBySlug( + slug: string +): Promise { + const filePath = path.join(BLOG_DIR, `${slug}.mdx`); + if (!fs.existsSync(filePath)) return null; + + const raw = fs.readFileSync(filePath, 'utf8'); + const { data, content: mdxSource } = matter(raw); + + const { content } = await compileMDX({ + source: mdxSource, + components: mdxComponents, + options: { parseFrontmatter: false }, + }); + + return { + slug, + title: String(data.title ?? ''), + excerpt: String(data.excerpt ?? ''), + coverImage: String(data.coverImage ?? ''), + publishedAt: String(data.publishedAt ?? ''), + author: { + name: String(data.author?.name ?? ''), + image: String(data.author?.image ?? ''), + }, + categories: Array.isArray(data.categories) + ? data.categories.map((c: unknown) => String(c)) + : [], + tags: Array.isArray(data.tags) + ? data.tags.map((t: unknown) => String(t)) + : [], + readingTime: typeof data.readingTime === 'number' ? data.readingTime : 0, + isFeatured: data.isFeatured === true, + content, + }; +} + +export function getRelatedPosts( + currentSlug: string, + tags: string[], + categories: string[], + limit = 3 +): MdxBlogPost[] { + const all = getAllBlogPosts().filter(p => p.slug !== currentSlug); + + const scored = all.map(post => { + const tagMatches = post.tags.filter(t => tags.includes(t)).length; + const catMatches = post.categories.filter(c => + categories.includes(c) + ).length; + return { post, score: tagMatches + catMatches }; + }); + + return scored + .filter(({ score }) => score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map(({ post }) => post); +} diff --git a/package-lock.json b/package-lock.json index 3cbf34b0..4ff47036 100644 --- a/package-lock.json +++ b/package-lock.json @@ -92,6 +92,7 @@ "dompurify": "^3.3.0", "embla-carousel-autoplay": "^8.6.0", "embla-carousel-react": "^8.6.0", + "gray-matter": "^4.0.3", "gsap": "^3.13.0", "input-otp": "^1.4.2", "js-cookie": "^3.0.5", @@ -102,6 +103,7 @@ "motion": "^12.34.0", "next": "^16.1.1", "next-auth": "^5.0.0-beta.29", + "next-mdx-remote": "^6.0.0", "next-themes": "^0.4.6", "nextjs-toploader": "^3.9.17", "p-limit": "^7.3.0", @@ -127,6 +129,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3", + "@tailwindcss/typography": "^0.5.19", "@types/node": "^20", "@types/p-limit": "^2.1.0", "@types/react": "19.2.7", @@ -201,7 +204,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", @@ -363,7 +365,6 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1674,6 +1675,60 @@ "integrity": "sha512-oYIkvu6E4n8fZH7ciQsVqamlUDeBnd6JbNYa1UWC/npkNzEHqM5saL3vk/nNorqdfjYwdcdmhLtYbnuwVy+3/Q==", "license": "MIT" }, + "node_modules/@mdx-js/mdx": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz", + "integrity": "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdx": "^2.0.0", + "acorn": "^8.0.0", + "collapse-white-space": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "estree-util-scope": "^1.0.0", + "estree-walker": "^3.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "markdown-extensions": "^2.0.0", + "recma-build-jsx": "^1.0.0", + "recma-jsx": "^1.0.0", + "recma-stringify": "^1.0.0", + "rehype-recma": "^1.0.0", + "remark-mdx": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "source-map": "^0.7.0", + "unified": "^11.0.0", + "unist-util-position-from-estree": "^2.0.0", + "unist-util-stringify-position": "^4.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@mdx-js/react": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", + "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", + "license": "MIT", + "dependencies": { + "@types/mdx": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=16", + "react": ">=16" + } + }, "node_modules/@mediapipe/tasks-vision": { "version": "0.10.17", "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", @@ -5446,6 +5501,19 @@ "tailwindcss": "4.1.18" } }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, "node_modules/@tanstack/react-table": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", @@ -6896,6 +6964,12 @@ "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", "license": "MIT" }, + "node_modules/@types/mdx": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", + "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==", + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -8026,7 +8100,6 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -8039,7 +8112,6 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -8313,6 +8385,15 @@ "license": "MIT", "peer": true }, + "node_modules/astring": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", + "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", + "license": "MIT", + "bin": { + "astring": "bin/astring" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -9410,6 +9491,16 @@ "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, + "node_modules/collapse-white-space": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz", + "integrity": "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -9641,6 +9732,19 @@ ], "license": "MIT" }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -10534,6 +10638,38 @@ "es6-promise": "^4.0.3" } }, + "node_modules/esast-util-from-estree": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/esast-util-from-estree/-/esast-util-from-estree-2.0.0.tgz", + "integrity": "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-visit": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/esast-util-from-js": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esast-util-from-js/-/esast-util-from-js-2.0.1.tgz", + "integrity": "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "acorn": "^8.0.0", + "esast-util-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -10833,6 +10969,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -10869,6 +11018,35 @@ "node": ">=4.0" } }, + "node_modules/estree-util-attach-comments": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-attach-comments/-/estree-util-attach-comments-3.0.0.tgz", + "integrity": "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-build-jsx": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/estree-util-build-jsx/-/estree-util-build-jsx-3.0.1.tgz", + "integrity": "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "estree-walker": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-util-is-identifier-name": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", @@ -10879,6 +11057,58 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-util-scope": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/estree-util-scope/-/estree-util-scope-1.0.0.tgz", + "integrity": "sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-to-js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/estree-util-to-js/-/estree-util-to-js-2.0.0.tgz", + "integrity": "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "astring": "^1.8.0", + "source-map": "^0.7.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-visit": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/estree-util-visit/-/estree-util-visit-2.0.0.tgz", + "integrity": "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -10937,6 +11167,18 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/eyes": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz", @@ -11426,6 +11668,43 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "license": "MIT", + "dependencies": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/gray-matter/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/gray-matter/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/gsap": { "version": "3.14.2", "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.14.2.tgz", @@ -11748,6 +12027,34 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-estree": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/hast-util-to-estree/-/hast-util-to-estree-3.1.3.tgz", + "integrity": "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-attach-comments": "^3.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-html": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", @@ -12286,6 +12593,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -12943,6 +13259,15 @@ "integrity": "sha512-8t6Q3TclQ4uZynJY9IGr2+SsIGwK9JHcO6ootkHCGA0CrQCRy+VkouYNO2xicET6b9al7QKzpebNow+gkpCL8g==", "license": "MIT" }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/kysely": { "version": "0.28.11", "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.11.tgz", @@ -13448,6 +13773,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/markdown-extensions": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-2.0.0.tgz", + "integrity": "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/markdown-it": { "version": "14.1.1", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", @@ -13660,6 +13997,23 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-mdx": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz", + "integrity": "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-mdx-expression": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", @@ -14009,6 +14363,108 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/micromark-extension-mdx-expression": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz", + "integrity": "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-mdx-expression": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-mdx-jsx": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-jsx/-/micromark-extension-mdx-jsx-3.0.2.tgz", + "integrity": "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "micromark-factory-mdx-expression": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdx-md": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-md/-/micromark-extension-mdx-md-2.0.0.tgz", + "integrity": "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdxjs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs/-/micromark-extension-mdxjs-3.0.0.tgz", + "integrity": "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==", + "license": "MIT", + "dependencies": { + "acorn": "^8.0.0", + "acorn-jsx": "^5.0.0", + "micromark-extension-mdx-expression": "^3.0.0", + "micromark-extension-mdx-jsx": "^3.0.0", + "micromark-extension-mdx-md": "^2.0.0", + "micromark-extension-mdxjs-esm": "^3.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdxjs-esm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs-esm/-/micromark-extension-mdxjs-esm-3.0.0.tgz", + "integrity": "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/micromark-factory-destination": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", @@ -14052,6 +14508,33 @@ "micromark-util-types": "^2.0.0" } }, + "node_modules/micromark-factory-mdx-expression": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-factory-mdx-expression/-/micromark-factory-mdx-expression-2.0.3.tgz", + "integrity": "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + } + }, "node_modules/micromark-factory-space": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", @@ -14253,6 +14736,31 @@ ], "license": "MIT" }, + "node_modules/micromark-util-events-to-acorn": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-events-to-acorn/-/micromark-util-events-to-acorn-2.0.3.tgz", + "integrity": "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "estree-util-visit": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "vfile-message": "^4.0.0" + } + }, "node_modules/micromark-util-html-tag-name": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", @@ -14744,6 +15252,28 @@ } } }, + "node_modules/next-mdx-remote": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/next-mdx-remote/-/next-mdx-remote-6.0.0.tgz", + "integrity": "sha512-cJEpEZlgD6xGjB4jL8BnI8FaYdN9BzZM4NwadPe1YQr7pqoWjg9EBCMv3nXBkuHqMRfv2y33SzUsuyNh9LFAQQ==", + "license": "MPL-2.0", + "dependencies": { + "@babel/code-frame": "^7.23.5", + "@mdx-js/mdx": "^3.0.1", + "@mdx-js/react": "^3.0.1", + "unist-util-remove": "^4.0.0", + "unist-util-visit": "^5.1.0", + "vfile": "^6.0.1", + "vfile-matter": "^5.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=7" + }, + "peerDependencies": { + "react": ">=16" + } + }, "node_modules/next-themes": { "version": "0.4.6", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", @@ -15434,6 +15964,20 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/potpack": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", @@ -16308,6 +16852,73 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, + "node_modules/recma-build-jsx": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-build-jsx/-/recma-build-jsx-1.0.0.tgz", + "integrity": "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-util-build-jsx": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/recma-jsx": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/recma-jsx/-/recma-jsx-1.0.1.tgz", + "integrity": "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==", + "license": "MIT", + "dependencies": { + "acorn-jsx": "^5.0.0", + "estree-util-to-js": "^2.0.0", + "recma-parse": "^1.0.0", + "recma-stringify": "^1.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/recma-parse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-parse/-/recma-parse-1.0.0.tgz", + "integrity": "sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "esast-util-from-js": "^2.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/recma-stringify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-stringify/-/recma-stringify-1.0.0.tgz", + "integrity": "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-util-to-js": "^2.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -16494,6 +17105,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/rehype-recma": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/rehype-recma/-/rehype-recma-1.0.0.tgz", + "integrity": "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "hast-util-to-estree": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/rehype-rewrite": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/rehype-rewrite/-/rehype-rewrite-4.0.4.tgz", @@ -16576,6 +17202,20 @@ "url": "https://jaywcjlove.github.io/#/sponsor" } }, + "node_modules/remark-mdx": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.1.tgz", + "integrity": "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==", + "license": "MIT", + "dependencies": { + "mdast-util-mdx": "^3.0.0", + "micromark-extension-mdxjs": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", @@ -16952,6 +17592,19 @@ "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", "license": "MIT" }, + "node_modules/section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -17331,6 +17984,15 @@ "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -17368,6 +18030,12 @@ "node": ">= 10.x" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/stats-gl": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", @@ -17622,6 +18290,15 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -18359,6 +19036,34 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unist-util-position-from-estree": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position-from-estree/-/unist-util-position-from-estree-2.0.0.tgz", + "integrity": "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove/-/unist-util-remove-4.0.0.tgz", + "integrity": "sha512-b4gokeGId57UVRX/eVKej5gXqGlc9+trkORhFJpu9raqZkZhU0zm8Doi05+HaiBsMEIJowL+2WtQ5ItjsngPXg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unist-util-stringify-position": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", @@ -18761,6 +19466,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/vfile-matter": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/vfile-matter/-/vfile-matter-5.0.1.tgz", + "integrity": "sha512-o6roP82AiX0XfkyTHyRCMXgHfltUNlXSEqCIS80f+mbAyiQBE2fxtDVMtseyytGx75sihiJFo/zR6r/4LTs2Cw==", + "license": "MIT", + "dependencies": { + "vfile": "^6.0.0", + "yaml": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vfile-message": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", @@ -19094,7 +19813,6 @@ "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/package.json b/package.json index a181672f..1b28936b 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "dompurify": "^3.3.0", "embla-carousel-autoplay": "^8.6.0", "embla-carousel-react": "^8.6.0", + "gray-matter": "^4.0.3", "gsap": "^3.13.0", "input-otp": "^1.4.2", "js-cookie": "^3.0.5", @@ -117,6 +118,7 @@ "motion": "^12.34.0", "next": "^16.1.1", "next-auth": "^5.0.0-beta.29", + "next-mdx-remote": "^6.0.0", "next-themes": "^0.4.6", "nextjs-toploader": "^3.9.17", "p-limit": "^7.3.0", @@ -142,6 +144,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3", + "@tailwindcss/typography": "^0.5.19", "@types/node": "^20", "@types/p-limit": "^2.1.0", "@types/react": "19.2.7",