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 = ` +

+ PostgreSQL version +

+ +
+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( + + {` + +image + +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: {}