Merge pull request #36208 from overleaf/ab-labs-description-headings-code

[web] Support headings and code in labs experiment descriptions

GitOrigin-RevId: 2a85f92fb5f0ce0696c1056087108ef2599547de
This commit is contained in:
Alexandre Bourdin
2026-09-17 11:56:43 +00:00
committed by Copybot
parent 329eca74c1
commit c861628684
3 changed files with 181 additions and 3 deletions
@@ -3,7 +3,21 @@ import { micromark } from 'micromark'
import DOMPurify from 'dompurify'
const PURIFY_CONFIG = {
ALLOWED_TAGS: ['#text', 'p', 'em', 'strong', 'a'],
ALLOWED_TAGS: [
'#text',
'p',
'em',
'strong',
'a',
'code',
'pre',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
],
ALLOWED_ATTR: ['href'],
}
@@ -27,12 +41,19 @@ function sanitizeDescription(description: string) {
/**
* Renders a labs experiment description from markdown to sanitized HTML.
* Only bold, italic, and links are supported.
* Only bold, italic, links, headings, inline code and code blocks are
* supported. Note that the language of a fenced code block is discarded, as
* the `class` attribute micromark puts it in is not allowed through.
*/
export const LabsDescription: FC<{ description: string }> = ({
description,
}) => {
const html = useMemo(() => sanitizeDescription(description), [description])
return <div dangerouslySetInnerHTML={{ __html: html }} />
return (
<div
className="labs-description"
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}
@@ -91,6 +91,96 @@
}
}
// Markdown rendered from an experiment description, see labs-description.tsx.
// Shown in the labs page widget, the experiment detail panel, and the split
// test admin preview, so the styles are not scoped to any of them.
.labs-description {
// Body text here is --font-size-02, and the experiment's own title is 18-20px,
// so the scale has to stay compact. There are not six distinct sizes to spend
// below the title, so the lower levels are separated by weight and colour
// instead — every level still differs from its neighbours.
h1,
h2,
h3,
h4,
h5,
h6 {
margin: var(--spacing-04) 0 var(--spacing-01);
color: var(--content-primary);
&:first-child {
margin-top: 0;
}
// Both the `code` rule below and `.labs-opt-in-wrapper strong` pin a size,
// which would otherwise shrink inline markup inside a heading
code,
strong {
font-size: inherit;
}
}
h1 {
font-size: var(--font-size-04);
line-height: var(--line-height-03);
font-weight: 600;
}
h2 {
font-size: var(--font-size-03);
line-height: var(--line-height-03);
font-weight: 600;
}
h3 {
font-size: var(--font-size-02);
line-height: var(--line-height-02);
font-weight: 700;
}
h4 {
font-size: var(--font-size-02);
line-height: var(--line-height-02);
font-weight: 600;
}
h5 {
font-size: var(--font-size-01);
line-height: var(--line-height-01);
font-weight: 700;
color: var(--content-secondary);
}
h6 {
font-size: var(--font-size-01);
line-height: var(--line-height-01);
font-weight: 600;
color: var(--content-secondary);
}
code {
padding: 0 var(--spacing-02);
border-radius: var(--border-radius-base);
background-color: var(--bg-light-secondary);
color: var(--content-primary);
font-size: var(--font-size-02);
word-break: break-word;
}
pre {
margin: 0 0 var(--spacing-04);
padding: var(--spacing-04);
border-radius: var(--border-radius-base);
background-color: var(--bg-light-secondary);
overflow-x: auto;
code {
padding: 0;
background-color: transparent;
}
}
}
// Feature Preview Modal
.labs-feature-preview-modal {
.modal-header {
@@ -27,4 +27,71 @@ describe('<LabsDescription />', function () {
expect(link?.getAttribute('rel')).to.equal('noreferrer noopener')
expect(link?.getAttribute('target')).to.equal('_BLANK')
})
it('renders inline code', function () {
const { container } = render(
<LabsDescription description="Set the `foo` option." />
)
const code = container.querySelector('code')
expect(code?.textContent).to.equal('foo')
expect(code?.parentElement?.nodeName).to.equal('P')
})
it('renders fenced code blocks without the language class', function () {
const { container } = render(
<LabsDescription description={'```js\nconst foo = 1\n```'} />
)
const code = container.querySelector('pre > code')
expect(code?.textContent).to.equal('const foo = 1\n')
expect(code?.getAttribute('class')).to.equal(null)
})
it('renders indented code blocks', function () {
const { container } = render(
<LabsDescription description=" const foo = 1" />
)
const code = container.querySelector('pre > code')
expect(code?.textContent).to.equal('const foo = 1\n')
})
// The visual hierarchy between the levels is in labs.scss, which the jsdom
// suite does not load, so this only covers each level surviving sanitization
it('renders headings at every level', function () {
const levels = [1, 2, 3, 4, 5, 6]
const { container } = render(
<LabsDescription
description={levels
.map(level => `${'#'.repeat(level)} Level ${level}`)
.join('\n\n')}
/>
)
for (const level of levels) {
expect(container.querySelector(`h${level}`)?.textContent).to.equal(
`Level ${level}`
)
}
})
it('escapes HTML inside code', function () {
const { container } = render(
<LabsDescription description="Use `<script>alert(1)</script>` here." />
)
expect(container.querySelector('script')).to.equal(null)
expect(container.querySelector('code')?.textContent).to.equal(
'<script>alert(1)</script>'
)
})
it('strips tags outside the supported subset', function () {
const { container } = render(
<LabsDescription description='<img src=x onerror="alert(1)">' />
)
expect(container.querySelector('img')).to.equal(null)
})
})