-
Notifications
You must be signed in to change notification settings - Fork 0
fix(content): replace hand-rolled MDX renderer with marked #28
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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' | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| it('renders code blocks', () => { | ||||||||||||||||||||||||||
|
|
@@ -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 -> 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(''); | ||||||||||||||||||||||||||
| 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(')'); | ||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, π Committable suggestion
Suggested change
π€ Prompt for AI AgentsSource: 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('<script>'); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| 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('<b>'); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| it('HTML-escapes dangerous characters in plain text', () => { | ||||||||||||||||||||||||||
| const result = renderLesson('Compare A < B and C > D'); | ||||||||||||||||||||||||||
| expect(result).toContain('<'); | ||||||||||||||||||||||||||
| expect(result).toContain('>'); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| 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', () => { | ||||||||||||||||||||||||||
|
|
@@ -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', () => { | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
There was a problem hiding this comment.
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.tstosrc/lib/mdx.test.tsso 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
Source: Coding guidelines