From c8616286848e33f45b408320ecac3501f24c6e1d Mon Sep 17 00:00:00 2001 From: Alexandre Bourdin Date: Fri, 14 Aug 2026 15:23:27 +0200 Subject: [PATCH] Merge pull request #36208 from overleaf/ab-labs-description-headings-code [web] Support headings and code in labs experiment descriptions GitOrigin-RevId: 2a85f92fb5f0ce0696c1056087108ef2599547de --- .../components/labs/labs-description.tsx | 27 +++++- .../frontend/stylesheets/modules/labs.scss | 90 +++++++++++++++++++ .../components/labs-description.test.tsx | 67 ++++++++++++++ 3 files changed, 181 insertions(+), 3 deletions(-) diff --git a/services/web/frontend/js/shared/components/labs/labs-description.tsx b/services/web/frontend/js/shared/components/labs/labs-description.tsx index f28405a444..d507008dd1 100644 --- a/services/web/frontend/js/shared/components/labs/labs-description.tsx +++ b/services/web/frontend/js/shared/components/labs/labs-description.tsx @@ -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
+ return ( +
+ ) } diff --git a/services/web/frontend/stylesheets/modules/labs.scss b/services/web/frontend/stylesheets/modules/labs.scss index 903bee7156..b697739b24 100644 --- a/services/web/frontend/stylesheets/modules/labs.scss +++ b/services/web/frontend/stylesheets/modules/labs.scss @@ -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 { diff --git a/services/web/test/frontend/shared/components/labs-description.test.tsx b/services/web/test/frontend/shared/components/labs-description.test.tsx index 7d964288ea..e96423807f 100644 --- a/services/web/test/frontend/shared/components/labs-description.test.tsx +++ b/services/web/test/frontend/shared/components/labs-description.test.tsx @@ -27,4 +27,71 @@ describe('', function () { expect(link?.getAttribute('rel')).to.equal('noreferrer noopener') expect(link?.getAttribute('target')).to.equal('_BLANK') }) + + it('renders inline code', function () { + const { container } = render( + + ) + + 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( + + ) + + 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( + + ) + + 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( + `${'#'.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( + + ) + + expect(container.querySelector('script')).to.equal(null) + expect(container.querySelector('code')?.textContent).to.equal( + '' + ) + }) + + it('strips tags outside the supported subset', function () { + const { container } = render( + + ) + + expect(container.querySelector('img')).to.equal(null) + }) })