Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
131 changes: 110 additions & 21 deletions src/lib/__tests__/mdx.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<p>Hello world</p>');
expect(renderLesson('Hello world').trim()).toBe('<p>Hello world</p>');
});

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('<p>First paragraph.</p>');
expect(result).toContain('<p>Second paragraph.</p>');
});

it('renders headings h1 through h4', () => {
expect(renderLesson('# Title')).toBe('<h1>Title</h1>');
expect(renderLesson('## Section')).toBe('<h2>Section</h2>');
expect(renderLesson('### Subsection')).toBe('<h3>Subsection</h3>');
expect(renderLesson('#### Detail')).toBe('<h4>Detail</h4>');
expect(renderLesson('# Title').trim()).toBe('<h1>Title</h1>');
expect(renderLesson('## Section').trim()).toBe('<h2>Section</h2>');
expect(renderLesson('### Subsection').trim()).toBe('<h3>Subsection</h3>');
expect(renderLesson('#### Detail').trim()).toBe('<h4>Detail</h4>');
});

it('renders bold text', () => {
expect(renderLesson('This is **bold** text')).toBe('<p>This is <strong>bold</strong> text</p>');
expect(renderLesson('This is **bold** text')).toContain('This is <strong>bold</strong> text');
});

it('renders italic text', () => {
expect(renderLesson('This is *italic* text')).toBe('<p>This is <em>italic</em> text</p>');
expect(renderLesson('This is *italic* text')).toContain('This is <em>italic</em> text');
});

it('renders inline code', () => {
expect(renderLesson('Use the `renderLesson` function')).toBe('<p>Use the <code>renderLesson</code> function</p>');
expect(renderLesson('Use the `renderLesson` function')).toContain(
'Use the <code>renderLesson</code> function'
);
Comment on lines +6 to +33

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ“ Maintainability & Code Quality | 🟑 Minor | ⚑ Quick win

Co-locate this suite with mdx.ts.

Move src/lib/__tests__/mdx.test.ts to src/lib/mdx.test.ts so the tests sit beside the module they cover. As per coding guidelines, **/*.test.{ts,tsx} tests must be placed next to the code they test.

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/__tests__/mdx.test.ts` around lines 6 - 33, Move the `mdx.test.ts`
suite from the `__tests__` directory to sit alongside `mdx.ts` in `src/lib`,
preserving all existing tests and behavior.

Source: Coding guidelines

});

it('renders code blocks', () => {
Expand All @@ -51,25 +52,117 @@ describe('mdx.ts β€” renderLesson', () => {
expect(result).toContain('</ul>');
});

it('renders blockquotes', () => {
expect(renderLesson('> A wise quote')).toBe('<blockquote>A wise quote</blockquote>');
it('renders ordered lists', () => {
const input = '1. First\n2. Second\n3. Third';
const result = renderLesson(input);
expect(result).toContain('<ol>');
expect(result).toContain('<li>First</li>');
expect(result).toContain('<li>Second</li>');
expect(result).toContain('<li>Third</li>');
expect(result).toContain('</ol>');
});

it('renders nested lists (ordered list with nested unordered sub-items)', () => {
// Matches the decision-flowchart pattern used in the search-term-triage lessons.
const input =
'1. **Is it relevant?**\n - No -> Add Negative\n - Yes -> Continue\n' +
' - Nested -> Deep item';
const result = renderLesson(input);
expect(result).toContain('<ol>');
expect(result).toContain('<strong>Is it relevant?</strong>');
expect(result).toContain('No -&gt; Add Negative');
// Two levels of nested <ul> for the sub-item and sub-sub-item.
expect(result.match(/<ul>/g)?.length).toBe(2);
});

it('renders tables', () => {
const input = '| Metric | Target |\n|---|---|\n| ACoS | 25% |\n| ROAS | 4.0 |';
const result = renderLesson(input);
expect(result).toContain('<table>');
expect(result).toContain('<th>Metric</th>');
expect(result).toContain('<th>Target</th>');
expect(result).toContain('<td>ACoS</td>');
expect(result).toContain('<td>25%</td>');
expect(result).toContain('</table>');
});

it('renders links with safe schemes', () => {
const result = renderLesson('[Amazon Ads help](https://advertising.amazon.com/help/foo)');
expect(result).toContain('<a href="https://advertising.amazon.com/help/foo"');
expect(result).toContain('rel="noopener noreferrer"');
expect(result).toContain('>Amazon Ads help</a>');
});

it('renders relative and anchor links', () => {
expect(renderLesson('[next lesson](/courses/foo/lessons/bar)')).toContain(
'href="/courses/foo/lessons/bar"'
);
expect(renderLesson('[jump](#section)')).toContain('href="#section"');
});

it('strips unsafe link schemes down to plain text', () => {
const result = renderLesson('[click me](javascript:alert(1))');
expect(result).not.toContain('<a ');
expect(result).not.toContain('javascript:');
expect(result).toContain('click me');
});

it('renders images with safe schemes', () => {
const result = renderLesson('![Campaign structure diagram](https://example.com/diagram.png)');
expect(result).toContain('<img src="https://example.com/diagram.png"');
expect(result).toContain('alt="Campaign structure diagram"');
});

it('drops images with unsafe schemes down to alt text', () => {
const result = renderLesson('![bad](javascript:alert(1))');
expect(result).not.toContain('<img');
expect(result).toContain('bad');
});

it('renders single-line blockquotes', () => {
expect(renderLesson('> A wise quote')).toContain('<blockquote>');
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';
Comment on lines +127 to +132

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ“ Maintainability & Code Quality | 🟑 Minor | ⚑ Quick win

Remove emoji literals from the test source.

The changed comment and fixture contain emoji characters, which the repository prohibits in code.

Proposed fix
-    // Matches the "🎯 Analogy" / "πŸ“Œ Key Takeaway" callout pattern used throughout lessons.
+    // Covers callout patterns used throughout lessons.
...
-      '> 🎯 **Analogy**: Three ways to pay for a taxi:\n' +
+      '> \u{1F3AF} **Analogy**: Three ways to pay for a taxi:\n' +

As per coding guidelines, **/*.{ts,tsx,js,jsx} must not use emojis in code.

πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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';
it('renders multi-line blockquote callouts with a nested list as one block', () => {
// Covers callout patterns used throughout lessons.
const input =
'> \u{1F3AF} **Analogy**: Three ways to pay for a taxi:\n' +
'> - **Fixed Bids** = flat-rate ride share\n' +
'> - **Dynamic** = surge pricing';
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/__tests__/mdx.test.ts` around lines 127 - 132, Remove the emoji
literals from the comment and input fixture in the multi-line blockquote test,
while preserving the callout text structure and test behavior. Update the
related reference in the test description/comment as needed without changing the
parsing scenario.

Source: Coding guidelines

const result = renderLesson(input);
expect(result.match(/<blockquote>/g)?.length).toBe(1);
expect(result).toContain('<strong>Analogy</strong>');
expect(result).toContain('<li><strong>Fixed Bids</strong> = flat-rate ride share</li>');
});

it('renders horizontal rules', () => {
expect(renderLesson('---')).toBe('<hr />');
expect(renderLesson('***')).toBe('<hr />');
expect(renderLesson('---')).toContain('<hr>');
expect(renderLesson('***')).toContain('<hr>');
});

it('HTML-escapes dangerous characters', () => {
it('escapes raw HTML blocks instead of passing them through', () => {
const result = renderLesson('<script>alert("xss")</script>');
expect(result).not.toContain('<script>');
expect(result).toContain('&lt;script&gt;');
});

it('escapes raw inline HTML tags instead of passing them through', () => {
const result = renderLesson('Some text with <b>raw html</b> inline.');
expect(result).not.toContain('<b>raw html</b>');
expect(result).toContain('&lt;b&gt;');
});

it('HTML-escapes dangerous characters in plain text', () => {
const result = renderLesson('Compare A < B and C > D');
expect(result).toContain('&lt;');
expect(result).toContain('&gt;');
});

it('handles combined formatting', () => {
const result = renderLesson('# Welcome\n\nThis is **bold** and *italic* with `code`.');
expect(result).toContain('<h1>Welcome</h1>');
expect(result).toContain('<p>This is <strong>bold</strong> and <em>italic</em> with <code>code</code>.</p>');
expect(result).toContain('This is <strong>bold</strong> and <em>italic</em> with <code>code</code>.');
});

it('handles unclosed code block by closing at end', () => {
Expand All @@ -82,11 +175,7 @@ describe('mdx.ts β€” renderLesson', () => {
});

it('returns empty string for empty input', () => {
expect(renderLesson('')).toBe('');
});

it('trims leading/trailing whitespace per line', () => {
expect(renderLesson(' Hello world ')).toBe('<p>Hello world</p>');
expect(renderLesson('').trim()).toBe('');
});

it('handles mixed list items with asterisk', () => {
Expand Down
Loading