perf(frontend): replace react-markdown with a small safe renderer (DownloadPage 120 KB -> 6 KB)
This commit is contained in:
parent
d629ccf496
commit
167405aed0
6 changed files with 129 additions and 166 deletions
|
|
@ -1,11 +1,11 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import Markdown from 'react-markdown'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Markdown } from '@shared/ui/Markdown'
|
||||
import { fetchReleases } from './api'
|
||||
|
||||
/**
|
||||
* Changelog feed. Markdown bodies are rendered by react-markdown, which does not
|
||||
* enable raw HTML — so a `<script>` or `onerror=` in a release body stays inert text.
|
||||
* Changelog feed. Markdown bodies go through our own renderer, which builds React
|
||||
* elements and never raw HTML, so a `<script>` or `onerror=` in a release body stays inert text.
|
||||
*/
|
||||
export function ReleaseNotes() {
|
||||
const { t, i18n } = useTranslation('download')
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { lazy, Suspense } from 'react'
|
||||
|
||||
// Loaded on demand so react-markdown stays out of the landing bundle.
|
||||
// Loaded on demand so the release-notes renderer stays out of the landing bundle.
|
||||
const DownloadPage = lazy(() => import('./DownloadPage'))
|
||||
|
||||
export default function DownloadRoute() {
|
||||
|
|
|
|||
95
frontend/src/shared/ui/Markdown.tsx
Normal file
95
frontend/src/shared/ui/Markdown.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { Fragment, type ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* A small Markdown renderer for release notes: headings, paragraphs, bullet/numbered lists,
|
||||
* fenced code, quotes, rules, and inline bold/italic/code/links. It builds React elements
|
||||
* (never HTML strings) and treats raw HTML as plain text, so a release body cannot inject markup.
|
||||
* Links are limited to http(s)/mailto targets.
|
||||
*/
|
||||
|
||||
const INLINE = /(`[^`\n]+`)|(\*\*[^*\n]+\*\*|__[^_\n]+__)|(\*[^*\n]+\*|_[^_\n]+_)|(\[[^\]\n]+\]\([^)\s]+\))|(https?:\/\/[^\s<>)]+)/g
|
||||
const SAFE_URL = /^(https?:|mailto:)/i
|
||||
|
||||
function renderInline(text: string, keyBase: string): ReactNode[] {
|
||||
const out: ReactNode[] = []
|
||||
let last = 0
|
||||
let n = 0
|
||||
for (const m of text.matchAll(INLINE)) {
|
||||
const at = m.index
|
||||
if (at > last) out.push(text.slice(last, at))
|
||||
const [tok] = m
|
||||
const key = `${keyBase}-${n++}`
|
||||
if (m[1]) out.push(<code key={key}>{tok.slice(1, -1)}</code>)
|
||||
else if (m[2]) out.push(<strong key={key}>{renderInline(tok.slice(2, -2), key)}</strong>)
|
||||
else if (m[3]) out.push(<em key={key}>{renderInline(tok.slice(1, -1), key)}</em>)
|
||||
else if (m[4]) {
|
||||
const close = tok.indexOf('](')
|
||||
const label = tok.slice(1, close)
|
||||
const href = tok.slice(close + 2, -1)
|
||||
out.push(SAFE_URL.test(href) ? <a key={key} href={href} rel="noopener noreferrer" target="_blank">{label}</a> : label)
|
||||
} else out.push(<a key={key} href={tok} rel="noopener noreferrer" target="_blank">{tok}</a>)
|
||||
last = at + tok.length
|
||||
}
|
||||
if (last < text.length) out.push(text.slice(last))
|
||||
return out
|
||||
}
|
||||
|
||||
const HEADING = /^(#{1,6})\s+(.*)$/
|
||||
const BULLET = /^\s*[-*+]\s+(.*)$/
|
||||
const NUMBERED = /^\s*\d+[.)]\s+(.*)$/
|
||||
const RULE = /^\s*([-*_])(\s*\1){2,}\s*$/
|
||||
|
||||
function isBlockStart(line: string): boolean {
|
||||
return HEADING.test(line) || BULLET.test(line) || NUMBERED.test(line) || RULE.test(line) || line.startsWith('```') || line.startsWith('>')
|
||||
}
|
||||
|
||||
export function Markdown({ children }: { children: string }) {
|
||||
const lines = children.replace(/\r\n?/g, '\n').split('\n')
|
||||
const blocks: ReactNode[] = []
|
||||
let i = 0
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
const key = `b${blocks.length}`
|
||||
if (!line.trim()) {
|
||||
i++
|
||||
} else if (line.startsWith('```')) {
|
||||
const code: string[] = []
|
||||
i++
|
||||
while (i < lines.length && !lines[i].startsWith('```')) code.push(lines[i++])
|
||||
i++
|
||||
blocks.push(<pre key={key}><code>{code.join('\n')}</code></pre>)
|
||||
} else if (HEADING.test(line)) {
|
||||
const [, hashes, text] = HEADING.exec(line)!
|
||||
const Tag = `h${hashes.length}` as 'h1'
|
||||
blocks.push(<Tag key={key}>{renderInline(text.trim(), key)}</Tag>)
|
||||
i++
|
||||
} else if (RULE.test(line)) {
|
||||
blocks.push(<hr key={key} />)
|
||||
i++
|
||||
} else if (BULLET.test(line) || NUMBERED.test(line)) {
|
||||
const ordered = NUMBERED.test(line)
|
||||
const re = ordered ? NUMBERED : BULLET
|
||||
const items: string[] = []
|
||||
while (i < lines.length && re.test(lines[i])) items.push(re.exec(lines[i++])![1])
|
||||
const List = ordered ? 'ol' : 'ul'
|
||||
blocks.push(
|
||||
<List key={key}>
|
||||
{items.map((it, k) => (
|
||||
<li key={k}>{renderInline(it, `${key}-${k}`)}</li>
|
||||
))}
|
||||
</List>,
|
||||
)
|
||||
} else if (line.startsWith('>')) {
|
||||
const quote: string[] = []
|
||||
while (i < lines.length && lines[i].startsWith('>')) quote.push(lines[i++].replace(/^>\s?/, ''))
|
||||
blocks.push(<blockquote key={key}>{renderInline(quote.join(' '), key)}</blockquote>)
|
||||
} else {
|
||||
const para: string[] = []
|
||||
while (i < lines.length && lines[i].trim() && !(para.length && isBlockStart(lines[i]))) para.push(lines[i++].trim())
|
||||
blocks.push(<p key={key}>{renderInline(para.join(' '), key)}</p>)
|
||||
}
|
||||
}
|
||||
|
||||
return <Fragment>{blocks}</Fragment>
|
||||
}
|
||||
30
frontend/src/shared/ui/tests/markdown.test.tsx
Normal file
30
frontend/src/shared/ui/tests/markdown.test.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { render } from '@testing-library/react'
|
||||
import { expect, test } from 'vitest'
|
||||
import { Markdown } from '../Markdown'
|
||||
|
||||
test('renders headings, lists, inline styles and code', () => {
|
||||
const { container } = render(
|
||||
<Markdown>{'# Title\n\nSome **bold**, *em* and `code`.\n\n- one\n- two\n\n1. first\n2. second\n\n```\nlet x = 1\n```'}</Markdown>,
|
||||
)
|
||||
expect(container.querySelector('h1')).toHaveTextContent('Title')
|
||||
expect(container.querySelector('strong')).toHaveTextContent('bold')
|
||||
expect(container.querySelector('em')).toHaveTextContent('em')
|
||||
expect(container.querySelector('p code')).toHaveTextContent('code')
|
||||
expect(container.querySelectorAll('ul > li')).toHaveLength(2)
|
||||
expect(container.querySelectorAll('ol > li')).toHaveLength(2)
|
||||
expect(container.querySelector('pre code')).toHaveTextContent('let x = 1')
|
||||
})
|
||||
|
||||
test('links keep http(s) targets and drop everything else', () => {
|
||||
const { container } = render(<Markdown>{'[ok](https://a.dev/x) [bad](javascript:alert(1)) https://b.dev'}</Markdown>)
|
||||
const hrefs = [...container.querySelectorAll('a')].map((a) => a.getAttribute('href'))
|
||||
expect(hrefs).toEqual(['https://a.dev/x', 'https://b.dev'])
|
||||
expect(container.textContent).toContain('bad')
|
||||
})
|
||||
|
||||
test('raw HTML stays inert text', () => {
|
||||
const { container } = render(<Markdown>{'<img src=x onerror=alert(1)>\n\n<script>alert(1)</script>'}</Markdown>)
|
||||
expect(container.querySelector('img')).toBeNull()
|
||||
expect(container.querySelector('script')).toBeNull()
|
||||
expect(container.textContent).toContain('<script>')
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue