|
1 | | -import fs from 'fs' |
2 | | -import path from 'path' |
3 | | -import matter from 'gray-matter' |
4 | 1 | import { notFound } from 'next/navigation' |
5 | | -import Link from 'next/link' |
| 2 | +import { CustomMDX } from '@/components/mdx' |
| 3 | +import { getCourseModules } from '../utils' |
6 | 4 |
|
7 | | -interface PageProps { |
8 | | - params: Promise<{ |
9 | | - slug: string |
10 | | - }> |
11 | | -} |
| 5 | +const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000' |
| 6 | + |
| 7 | +export async function generateStaticParams() { |
| 8 | + const modules = getCourseModules() |
12 | 9 |
|
13 | | -interface UnitData { |
14 | | - title: string |
15 | | - content: string |
16 | | - frontmatter: Record<string, unknown> |
| 10 | + return modules.map((module) => ({ |
| 11 | + slug: module.slug, |
| 12 | + })) |
17 | 13 | } |
18 | 14 |
|
19 | | -async function getUnitBySlug(slug: string): Promise<UnitData | null> { |
20 | | - try { |
21 | | - // Parse the slug to get unit directory and file name |
22 | | - const [unitDir, fileName] = slug.split('/') |
23 | | - |
24 | | - if (!unitDir || !fileName) { |
25 | | - return null |
26 | | - } |
| 15 | +export function generateMetadata({ params }: { params: { slug: string } }) { |
| 16 | + const courseModule = getCourseModules().find((module) => module.slug === params.slug) |
| 17 | + if (!courseModule) { |
| 18 | + return |
| 19 | + } |
27 | 20 |
|
28 | | - const filePath = path.join(process.cwd(), 'app/course/units', unitDir, `${fileName}.mdx`) |
29 | | - |
30 | | - if (!fs.existsSync(filePath)) { |
31 | | - return null |
32 | | - } |
| 21 | + const { |
| 22 | + title, |
| 23 | + publishedAt: publishedTime, |
| 24 | + summary: description, |
| 25 | + image, |
| 26 | + } = courseModule.metadata |
| 27 | + const ogImage = image |
| 28 | + ? image |
| 29 | + : `${baseUrl}/og?title=${encodeURIComponent(title)}` |
33 | 30 |
|
34 | | - const source = fs.readFileSync(filePath, 'utf8') |
35 | | - const { data: frontmatter, content } = matter(source) |
36 | | - |
37 | | - // Extract title from the first heading or use filename |
38 | | - const titleMatch = content.match(/^#\s+(.+)$/m) |
39 | | - const title = titleMatch ? titleMatch[1] : fileName |
40 | | - |
41 | | - return { |
| 31 | + return { |
| 32 | + title, |
| 33 | + description, |
| 34 | + openGraph: { |
| 35 | + title, |
| 36 | + description, |
| 37 | + type: 'article', |
| 38 | + publishedTime, |
| 39 | + url: `${baseUrl}/course/${courseModule.slug}`, |
| 40 | + images: [ |
| 41 | + { |
| 42 | + url: ogImage, |
| 43 | + }, |
| 44 | + ], |
| 45 | + }, |
| 46 | + twitter: { |
| 47 | + card: 'summary_large_image', |
42 | 48 | title, |
43 | | - content, |
44 | | - frontmatter |
45 | | - } |
46 | | - } catch (error) { |
47 | | - console.error('Error reading unit file:', error) |
48 | | - return null |
| 49 | + description, |
| 50 | + images: [ogImage], |
| 51 | + }, |
49 | 52 | } |
50 | 53 | } |
51 | 54 |
|
52 | | -export default async function UnitPage({ params }: PageProps) { |
53 | | - const { slug } = await params |
54 | | - const unit = await getUnitBySlug(slug) |
55 | | - |
56 | | - if (!unit) { |
| 55 | +export default async function CourseModule({ params }: { params: { slug: string } }) { |
| 56 | + const courseModule = getCourseModules().find((module) => module.slug === params.slug) |
| 57 | + |
| 58 | + if (!courseModule) { |
57 | 59 | notFound() |
58 | 60 | } |
59 | 61 |
|
60 | 62 | return ( |
61 | | - <div className="min-h-screen bg-gray-50"> |
62 | | - <div className="max-w-4xl mx-auto px-4 py-8"> |
63 | | - <div className="mb-8"> |
64 | | - <nav className="mb-6"> |
65 | | - <Link |
66 | | - href="/course/units" |
67 | | - className="text-blue-600 hover:text-blue-800 font-medium" |
68 | | - > |
69 | | - ← Back to Course |
70 | | - </Link> |
71 | | - </nav> |
72 | | - |
73 | | - <h1 className="text-4xl font-bold text-gray-900 mb-4"> |
74 | | - {unit.title} |
75 | | - </h1> |
76 | | - </div> |
77 | | - |
78 | | - <article className="bg-white rounded-lg shadow-md overflow-hidden"> |
79 | | - <div className="p-8"> |
80 | | - <div className="prose prose-lg max-w-none"> |
81 | | - <div className="whitespace-pre-wrap text-gray-700 leading-relaxed"> |
82 | | - {unit.content} |
83 | | - </div> |
84 | | - </div> |
| 63 | + <section className="items-center justify-center"> |
| 64 | + <script |
| 65 | + type="application/ld+json" |
| 66 | + suppressHydrationWarning |
| 67 | + dangerouslySetInnerHTML={{ |
| 68 | + __html: JSON.stringify({ |
| 69 | + '@context': 'https://schema.org', |
| 70 | + '@type': 'Course', |
| 71 | + headline: courseModule.metadata.title, |
| 72 | + datePublished: courseModule.metadata.publishedAt, |
| 73 | + dateModified: courseModule.metadata.publishedAt, |
| 74 | + description: courseModule.metadata.summary, |
| 75 | + image: courseModule.metadata.image |
| 76 | + ? `${baseUrl}${courseModule.metadata.image}` |
| 77 | + : `/og?title=${encodeURIComponent(courseModule.metadata.title)}`, |
| 78 | + url: `${baseUrl}/course/${courseModule.slug}`, |
| 79 | + author: { |
| 80 | + '@type': 'Person', |
| 81 | + name: 'CAMEL MCP Course', |
| 82 | + }, |
| 83 | + }), |
| 84 | + }} |
| 85 | + /> |
| 86 | + <div className="max-w-[900px] min-h-screen mx-auto justify-center items-center py-24"> |
| 87 | + <div className="flex flex-col items-center rounded-xl gap-8 w-full"> |
| 88 | + <h1 className="title font-semibold text-4xl tracking-tighter leading-tight text-center font-[family-name:var(--font-main)]"> |
| 89 | + {courseModule.metadata.title || `Module ${courseModule.slug}`} |
| 90 | + </h1> |
| 91 | + {courseModule.metadata.publishedAt && ( |
| 92 | + <div className="flex flex-row justify-center gap-8 w-full items-center mt-2 mb-8 text-sm border-b border-neutral-200 pt-2 pb-8 font-[family-name:var(--font-mono)]"> |
| 93 | + <p className="text-sm text-neutral-600 dark:text-neutral-400"> |
| 94 | + <span className="font-bold italic mr-2">Written by</span> {courseModule.metadata.author || 'CAMEL-AI'} |
| 95 | + </p> |
| 96 | + <p className="text-sm text-neutral-600 dark:text-neutral-400"> |
| 97 | + <span className="font-bold italic mr-2">Reviewed by</span> {courseModule.metadata.reviewer || 'CAMEL-AI'} |
| 98 | + </p> |
85 | 99 | </div> |
| 100 | + )} |
| 101 | + </div> |
| 102 | + <article className="prose prose-lg max-w-none font-[family-name:var(--font-sans)]"> |
| 103 | + {await CustomMDX({ source: courseModule.content })} |
86 | 104 | </article> |
87 | 105 | </div> |
88 | | - </div> |
| 106 | + </section> |
89 | 107 | ) |
90 | 108 | } |
91 | | - |
92 | | -// Generate static params for all units |
93 | | -export async function generateStaticParams() { |
94 | | - const unitsDir = path.join(process.cwd(), 'app/course/units') |
95 | | - const unitDirs = fs.readdirSync(unitsDir).filter(dir => |
96 | | - fs.statSync(path.join(unitsDir, dir)).isDirectory() && dir.startsWith('unit') |
97 | | - ) |
98 | | - |
99 | | - const params: { slug: string }[] = [] |
100 | | - |
101 | | - for (const unitDir of unitDirs) { |
102 | | - const unitPath = path.join(unitsDir, unitDir) |
103 | | - const files = fs.readdirSync(unitPath).filter(file => file.endsWith('.mdx')) |
104 | | - |
105 | | - for (const file of files) { |
106 | | - const fileName = file.replace('.mdx', '') |
107 | | - params.push({ |
108 | | - slug: `${unitDir}/${fileName}` |
109 | | - }) |
110 | | - } |
111 | | - } |
112 | | - |
113 | | - return params |
114 | | -} |
0 commit comments