diff --git a/package.json b/package.json index 8d2913d..70107ae 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@sentry/nextjs": "^10.66.0", "clsx": "^2.1.1", "jose": "^6.2.3", + "marked": "^18.0.6", "next": "^16.0.0", "paymongo": "^1.3.2", "pg": "^8.22.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9909fd2..90cefc4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: jose: specifier: ^6.2.3 version: 6.2.3 + marked: + specifier: ^18.0.6 + version: 18.0.6 next: specifier: ^16.0.0 version: 16.2.10(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -3106,6 +3109,11 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + marked@18.0.6: + resolution: {integrity: sha512-MrV5puXBfuiy6wl6DLaq3BtIJQAJToAd5zt/ZKhRfGRAuFPALE7/4Y7jnxRQoEgK/pBgurGqLyAuRgZ2xOjr6w==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -7277,6 +7285,8 @@ snapshots: dependencies: semver: 7.8.5 + marked@18.0.6: {} + math-intrinsics@1.1.0: {} media-engine@1.0.3: {} diff --git a/src/app/(dashboard)/courses/[courseSlug]/lessons/[lessonSlug]/lesson.module.css b/src/app/(dashboard)/courses/[courseSlug]/lessons/[lessonSlug]/lesson.module.css index 50f1666..5678453 100644 --- a/src/app/(dashboard)/courses/[courseSlug]/lessons/[lessonSlug]/lesson.module.css +++ b/src/app/(dashboard)/courses/[courseSlug]/lessons/[lessonSlug]/lesson.module.css @@ -77,6 +77,42 @@ padding: 0; } +.lessonBody a { + color: var(--accent); + text-decoration: underline; + text-underline-offset: 2px; +} +.lessonBody a:hover { + color: var(--accent-strong, var(--accent)); +} + +.lessonBody img { + max-width: 100%; + height: auto; + border-radius: var(--radius-md); + margin: var(--space-4) 0; +} + +.lessonBody table { + width: 100%; + border-collapse: collapse; + margin: var(--space-4) 0; + font-size: var(--text-sm); +} + +.lessonBody th, +.lessonBody td { + border: 1px solid var(--border); + padding: var(--space-2) var(--space-3); + text-align: left; +} + +.lessonBody th { + background: var(--surface-2); + font-weight: 600; + color: var(--ink-900); +} + .actions { margin: var(--space-8) 0; display: flex; diff --git a/src/lib/__tests__/mdx.test.ts b/src/lib/__tests__/mdx.test.ts index ea43a51..eaabd90 100644 --- a/src/lib/__tests__/mdx.test.ts +++ b/src/lib/__tests__/mdx.test.ts @@ -3,33 +3,34 @@ import { renderLesson } from '@/lib/mdx'; describe('mdx.ts — renderLesson', () => { it('renders plain text as a paragraph', () => { - expect(renderLesson('Hello world')).toBe('
Hello world
'); + expect(renderLesson('Hello world').trim()).toBe('Hello world
'); }); it('renders multiple paragraphs separated by blank lines', () => { - const input = 'First paragraph.\n\nSecond paragraph.'; - const result = renderLesson(input); + const result = renderLesson('First paragraph.\n\nSecond paragraph.'); expect(result).toContain('First paragraph.
'); expect(result).toContain('Second paragraph.
'); }); it('renders headings h1 through h4', () => { - expect(renderLesson('# Title')).toBe('This is bold text
'); + expect(renderLesson('This is **bold** text')).toContain('This is bold text'); }); it('renders italic text', () => { - expect(renderLesson('This is *italic* text')).toBe('This is italic text
'); + expect(renderLesson('This is *italic* text')).toContain('This is italic text'); }); it('renders inline code', () => { - expect(renderLesson('Use the `renderLesson` function')).toBe('Use the renderLesson function
renderLesson function'
+ );
});
it('renders code blocks', () => {
@@ -51,25 +52,117 @@ describe('mdx.ts — renderLesson', () => {
expect(result).toContain('');
});
- it('renders blockquotes', () => {
- expect(renderLesson('> A wise quote')).toBe('A wise quote'); + it('renders ordered lists', () => { + const input = '1. First\n2. Second\n3. Third'; + const result = renderLesson(input); + expect(result).toContain('
| Metric | '); + expect(result).toContain('Target | '); + expect(result).toContain('ACoS | '); + expect(result).toContain('25% | '); + expect(result).toContain('
|---|
{
+ const result = renderLesson(')');
+ expect(result).not.toContain(''); + expect(renderLesson('> A wise quote')).toContain('A wise quote'); + }); + + it('renders multi-line blockquote callouts with a nested list as one block', () => { + // Matches the "🎯 Analogy" / "📌 Key Takeaway" callout pattern used throughout lessons. + const input = + '> 🎯 **Analogy**: Three ways to pay for a taxi:\n' + + '> - **Fixed Bids** = flat-rate ride share\n' + + '> - **Dynamic** = surge pricing'; + const result = renderLesson(input); + expect(result.match(//g)?.length).toBe(1); + expect(result).toContain('Analogy'); + expect(result).toContain('- Fixed Bids = flat-rate ride share
'); }); it('renders horizontal rules', () => { - expect(renderLesson('---')).toBe('
'); - expect(renderLesson('***')).toBe('
'); + expect(renderLesson('---')).toContain('
'); + expect(renderLesson('***')).toContain('
'); }); - it('HTML-escapes dangerous characters', () => { + it('escapes raw HTML blocks instead of passing them through', () => { const result = renderLesson(''); expect(result).not.toContain('