Skip to content
Open
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
116 changes: 116 additions & 0 deletions website/__tests__/markdown.integration.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import Markdown from '~/components/ui/markdown'

// Header in the style of GitHub READMEs, e.g. supabase/supa_audit
const README_HTML = `<!-- badges -->
<p align="center">
<a href="https://github.com/supabase/supa_audit"><img src="https://img.shields.io/badge/postgresql-12+-blue.svg" alt="PostgreSQL version" height="18"></a>
</p>

<details>
<summary>Installation</summary>

Install with the \`dbdev\` CLI.

</details>
`

describe('Markdown Integration', () => {
it('renders raw HTML from package READMEs instead of escaping it', () => {
const { container } = render(<Markdown>{README_HTML}</Markdown>)

// Tags and comments are not printed as text
expect(container).not.toHaveTextContent('<p')
expect(container).not.toHaveTextContent('<img')
expect(container).not.toHaveTextContent('badges')

// Badge image is rendered inside its link and centered paragraph
const badge = screen.getByAltText('PostgreSQL version')
expect(badge.tagName).toBe('IMG')
expect(badge).toHaveAttribute(
'src',
'https://img.shields.io/badge/postgresql-12+-blue.svg'
)
expect(badge).toHaveAttribute('height', '18')
expect(badge.closest('a')).toHaveAttribute(
'href',
'https://github.com/supabase/supa_audit'
)
expect(badge.closest('p')).toHaveAttribute('align', 'center')

// Markdown nested in a collapsible section still renders
const details = container.querySelector('details')
expect(details).not.toBeNull()
expect(details!.querySelector('summary')).toHaveTextContent('Installation')
expect(details!.querySelector('p code')).toHaveTextContent('dbdev')
})

it('strips scripts, event handlers and dangerous URLs from raw HTML', () => {
const { container } = render(
<Markdown>
{`<script>alert('script')</script>

<img src="x" onerror="alert('onerror')" alt="image">

<a href="javascript:alert('html')">html link</a> [markdown link](javascript:alert('md'))

<iframe src="https://example.com"></iframe>

<div id="__NEXT_DATA__">clobbered</div>`}
</Markdown>
)

expect(container.querySelector('script')).toBeNull()
expect(container.querySelector('iframe')).toBeNull()
expect(container.querySelector('[onerror]')).toBeNull()
container.querySelectorAll('a').forEach((link) => {
expect(link.getAttribute('href') ?? '').not.toMatch(/^javascript:/i)
})

// ids from READMEs can't clobber ids the page relies on
expect(document.getElementById('__NEXT_DATA__')).toBeNull()
})

it('keeps highlighting fenced code blocks with a copy button', () => {
const { container } = render(
<Markdown>{'```sql\nselect 1;\n```'}</Markdown>
)

const code = container.querySelector('pre > code')
expect(code).toHaveClass('hljs', 'language-sql')
expect(code!.querySelector('.hljs-keyword')).toHaveTextContent('select')
expect(screen.getByRole('button', { name: 'Copy' })).toBeInTheDocument()
})

it('keeps in-page links pointing at their targets', () => {
render(
<Markdown>
{`Records are versioned[^1]. See [the anchor](#anchor).

<a id="anchor"></a>

[^1]: Each change is stored in \`audit.record_version\`.`}
</Markdown>
)

const targetOf = (link: HTMLElement) =>
document.getElementById(link.getAttribute('href')!.slice(1))

// Footnote reference and back reference resolve to each other, using the
// same fragment as GitHub
const ref = screen.getByRole('link', { name: '1' })
expect(ref).toHaveAttribute('href', '#user-content-fn-1')
const footnote = targetOf(ref)
expect(footnote).toHaveTextContent('audit.record_version')
const backref = footnote!.querySelector<HTMLElement>(
'a[data-footnote-backref]'
)
expect(targetOf(backref!)).toBe(ref)

// Links to anchors defined in raw HTML resolve too
const anchor = targetOf(screen.getByRole('link', { name: 'the anchor' }))
expect(anchor).not.toBeNull()
expect(anchor!.tagName).toBe('A')
})
})
29 changes: 28 additions & 1 deletion website/components/ui/markdown.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { ComponentPropsWithoutRef } from 'react'
import ReactMarkdown from 'react-markdown'
import rehypeHighlight from 'rehype-highlight'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize from 'rehype-sanitize'
import remarkGfm from 'remark-gfm'
import { cn } from '~/lib/utils'
import CopyButton from './copy-button'
Expand Down Expand Up @@ -32,6 +34,22 @@ function childrenToText(children: any): string {
return ''
}

type HastNode = {
properties?: { href?: unknown }
children?: HastNode[]
}

// rehype-sanitize prefixes ids and names with `user-content-` to prevent DOM
// clobbering, so in-page links like footnotes need the same prefix to resolve
function rehypePrefixInPageLinks() {
return function prefixLinks({ properties, children }: HastNode) {
if (typeof properties?.href === 'string' && /^#./.test(properties.href)) {
properties.href = `#user-content-${properties.href.slice(1)}`
}
children?.forEach(prefixLinks)
}
}

const DEFAULT_COMPONENTS: MarkdownProps['components'] = {
pre({ node, children, className, ...props }) {
return (
Expand Down Expand Up @@ -115,7 +133,16 @@ const Markdown = ({
}: MarkdownProps) => (
<ReactMarkdown
remarkPlugins={[remarkGfm, ...(remarkPlugins || [])]}
rehypePlugins={[rehypeHighlight, ...(rehypePlugins || [])]}
// rehype-sanitize prefixes footnote ids itself
remarkRehypeOptions={{ clobberPrefix: '' }}
// Sanitize before highlighting so highlight.js classes are kept
rehypePlugins={[
rehypeRaw,
rehypeSanitize,
rehypePrefixInPageLinks,
rehypeHighlight,
...(rehypePlugins || []),
]}
className={cn('prose lg:prose-xl max-w-none', className)}
components={{
...DEFAULT_COMPONENTS,
Expand Down
2 changes: 2 additions & 0 deletions website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@
"react-hook-form": "^7.71.1",
"react-markdown": "^9.1.0",
"rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
Expand Down
Loading