diff --git a/website/__tests__/markdown.integration.test.tsx b/website/__tests__/markdown.integration.test.tsx
new file mode 100644
index 00000000..c89ab28e
--- /dev/null
+++ b/website/__tests__/markdown.integration.test.tsx
@@ -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 = `
+
+
+
+
+
+Installation
+
+Install with the \`dbdev\` CLI.
+
+
+`
+
+describe('Markdown Integration', () => {
+ it('renders raw HTML from package READMEs instead of escaping it', () => {
+ const { container } = render({README_HTML})
+
+ // Tags and comments are not printed as text
+ expect(container).not.toHaveTextContent(' {
+ const { container } = render(
+
+ {`
+
+
+
+html link [markdown link](javascript:alert('md'))
+
+
+
+clobbered
`}
+
+ )
+
+ 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(
+ {'```sql\nselect 1;\n```'}
+ )
+
+ 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(
+
+ {`Records are versioned[^1]. See [the anchor](#anchor).
+
+
+
+[^1]: Each change is stored in \`audit.record_version\`.`}
+
+ )
+
+ 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(
+ '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')
+ })
+})
diff --git a/website/components/ui/markdown.tsx b/website/components/ui/markdown.tsx
index 602b97d0..00ae04c7 100644
--- a/website/components/ui/markdown.tsx
+++ b/website/components/ui/markdown.tsx
@@ -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'
@@ -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 (
@@ -115,7 +133,16 @@ const Markdown = ({
}: MarkdownProps) => (
= 0.4'}
+ hast-util-from-parse5@8.0.3:
+ resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==}
+
hast-util-is-element@3.0.0:
resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==}
+ hast-util-parse-selector@4.0.0:
+ resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
+
+ hast-util-raw@9.1.0:
+ resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==}
+
+ hast-util-sanitize@5.0.2:
+ resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==}
+
hast-util-to-estree@3.1.3:
resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==}
@@ -2837,6 +2855,9 @@ packages:
hast-util-to-jsx-runtime@2.3.6:
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
+ hast-util-to-parse5@8.0.1:
+ resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==}
+
hast-util-to-string@3.0.1:
resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==}
@@ -2846,6 +2867,9 @@ packages:
hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
+ hastscript@9.0.1:
+ resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
+
highlight.js@11.11.1:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
@@ -3479,6 +3503,9 @@ packages:
parse-entities@4.0.2:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
+ parse5@7.3.0:
+ resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
+
parse5@8.0.0:
resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==}
@@ -3724,9 +3751,15 @@ packages:
rehype-highlight@7.0.2:
resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==}
+ rehype-raw@7.0.0:
+ resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
+
rehype-recma@1.0.0:
resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==}
+ rehype-sanitize@6.0.0:
+ resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==}
+
remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
@@ -4143,6 +4176,9 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+ vfile-location@5.0.3:
+ resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
+
vfile-message@4.0.3:
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
@@ -4234,6 +4270,9 @@ packages:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
+ web-namespaces@2.0.1:
+ resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
+
webidl-conversions@8.0.1:
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
engines: {node: '>=20'}
@@ -6969,10 +7008,47 @@ snapshots:
dependencies:
function-bind: 1.1.2
+ hast-util-from-parse5@8.0.3:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/unist': 3.0.3
+ devlop: 1.1.0
+ hastscript: 9.0.1
+ property-information: 7.1.0
+ vfile: 6.0.3
+ vfile-location: 5.0.3
+ web-namespaces: 2.0.1
+
hast-util-is-element@3.0.0:
dependencies:
'@types/hast': 3.0.4
+ hast-util-parse-selector@4.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+
+ hast-util-raw@9.1.0:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/unist': 3.0.3
+ '@ungap/structured-clone': 1.3.0
+ hast-util-from-parse5: 8.0.3
+ hast-util-to-parse5: 8.0.1
+ html-void-elements: 3.0.0
+ mdast-util-to-hast: 13.2.1
+ parse5: 7.3.0
+ unist-util-position: 5.0.0
+ unist-util-visit: 5.1.0
+ vfile: 6.0.3
+ web-namespaces: 2.0.1
+ zwitch: 2.0.4
+
+ hast-util-sanitize@5.0.2:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@ungap/structured-clone': 1.3.0
+ unist-util-position: 5.0.0
+
hast-util-to-estree@3.1.3:
dependencies:
'@types/estree': 1.0.8
@@ -7028,6 +7104,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ hast-util-to-parse5@8.0.1:
+ dependencies:
+ '@types/hast': 3.0.4
+ comma-separated-tokens: 2.0.3
+ devlop: 1.1.0
+ property-information: 7.1.0
+ space-separated-tokens: 2.0.2
+ web-namespaces: 2.0.1
+ zwitch: 2.0.4
+
hast-util-to-string@3.0.1:
dependencies:
'@types/hast': 3.0.4
@@ -7043,6 +7129,14 @@ snapshots:
dependencies:
'@types/hast': 3.0.4
+ hastscript@9.0.1:
+ dependencies:
+ '@types/hast': 3.0.4
+ comma-separated-tokens: 2.0.3
+ hast-util-parse-selector: 4.0.0
+ property-information: 7.1.0
+ space-separated-tokens: 2.0.2
+
highlight.js@11.11.1: {}
html-encoding-sniffer@6.0.0:
@@ -7961,6 +8055,10 @@ snapshots:
is-decimal: 2.0.1
is-hexadecimal: 2.0.1
+ parse5@7.3.0:
+ dependencies:
+ entities: 6.0.1
+
parse5@8.0.0:
dependencies:
entities: 6.0.1
@@ -8217,6 +8315,12 @@ snapshots:
unist-util-visit: 5.0.0
vfile: 6.0.3
+ rehype-raw@7.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+ hast-util-raw: 9.1.0
+ vfile: 6.0.3
+
rehype-recma@1.0.0:
dependencies:
'@types/estree': 1.0.8
@@ -8225,6 +8329,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ rehype-sanitize@6.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+ hast-util-sanitize: 5.0.2
+
remark-gfm@4.0.1:
dependencies:
'@types/mdast': 4.0.4
@@ -8830,6 +8939,11 @@ snapshots:
util-deprecate@1.0.2: {}
+ vfile-location@5.0.3:
+ dependencies:
+ '@types/unist': 3.0.3
+ vfile: 6.0.3
+
vfile-message@4.0.3:
dependencies:
'@types/unist': 3.0.3
@@ -8880,6 +8994,8 @@ snapshots:
dependencies:
xml-name-validator: 5.0.0
+ web-namespaces@2.0.1: {}
+
webidl-conversions@8.0.1: {}
whatwg-mimetype@4.0.0: {}