fix(editor): open plain details blocks in rich markdown mode (#19784)

* fix(editor): allow plain details blocks in rich markdown mode

* fix(editor): preserve case-sensitive details class values
This commit is contained in:
SahilZ0810
2026-09-20 22:05:30 -07:00
committed by GitHub
parent 2d697a4012
commit ffb79c71e0
6 changed files with 151 additions and 3 deletions
@@ -3,6 +3,7 @@ import {
extractDetailsSummaryHtml,
isEditableDetailsHtmlBlock,
matchDetailsHtmlBlock,
normalizeDetailsOpeningTag,
parseDetailsAttributes,
parseToggleHeadingVariant,
type DetailsHtmlBlock
@@ -26,6 +27,45 @@ afterEach(() => {
})
describe('details markdown html', () => {
it.each([
['<details>', '<details class="orca-details">'],
['<details open="open">', '<details class="orca-details" open>'],
['<details CLASS="orca-details">', '<details class="orca-details">'],
["<details Class='orca-details'>", '<details class="orca-details">'],
['<details cLaSs=orca-details>', '<details class="orca-details">'],
[
"<details open data-orca-toggle = 'heading-2' class='orca-details'>",
'<details class="orca-details" data-orca-toggle="heading-2" open>'
]
])('normalizes supported opening tag %s like the serializer', (input, expected) => {
expect(normalizeDetailsOpeningTag(input)).toBe(expected)
})
it.each([
'<details id="keep">',
'<details class="custom">',
'<details class="ORCA-DETAILS">',
"<details CLASS='Orca-Details'>",
'<details Class=ORCA-DETAILS>',
'<details data-orca-toggle="heading-6">',
'<details open="false">',
'<detailsish>',
'</details>',
'<summary>',
'<!-- <details> -->'
])('leaves noncanonical or unrelated fragment %s unchanged', (fragment) => {
expect(normalizeDetailsOpeningTag(fragment)).toBe(fragment)
})
it.each(['ORCA-DETAILS', 'Orca-Details'])(
'keeps case-sensitive class %s out of editable details nodes',
(className) => {
expect(
isEditableHtml(`<details class="${className}"><summary>Toggle</summary>Body</details>`)
).toBe(false)
}
)
it('extracts leading summary html without regex capture', () => {
const matchSpy = vi.spyOn(String.prototype, 'match')
const inner = `\n<SUMMARY>${'Heading line\n'.repeat(1_000)}</SUMMARY><p>Body</p>`
@@ -184,7 +184,11 @@ function hasOnlySupportedDetailsAttributes(rawAttributes: string): boolean {
return (
rawAttributes
.replace(/\s+open(?:\s*=\s*(?:""|"open"|''|'open'|open))?(?=\s|$)/giu, '')
.replace(/\s+class\s*=\s*(?:"orca-details"|'orca-details'|orca-details)(?=\s|$)/giu, '')
// HTML attribute names ignore case; class tokens do not.
.replace(
/\s+[cC][lL][aA][sS][sS]\s*=\s*(?:"orca-details"|'orca-details'|orca-details)(?=\s|$)/gu,
''
)
.replace(
/\s+data-orca-toggle\s*=\s*(?:"heading-[1-5]"|'heading-[1-5]'|heading-[1-5])(?=\s|$)/giu,
''
@@ -193,6 +197,15 @@ function hasOnlySupportedDetailsAttributes(rawAttributes: string): boolean {
)
}
export function normalizeDetailsOpeningTag(fragment: string): string {
const match = fragment.match(/^<details(\s[^<>]*)?>$/i)
const attributes = match?.[1] ?? ''
if (!match || !hasOnlySupportedDetailsAttributes(attributes)) {
return fragment
}
return `<details ${renderDetailsAttributes(parseDetailsAttributes(attributes))}>`
}
function hasOnlyPlainParagraphAndBreakTags(content: string): boolean {
return !/<p\b(?!\s*>)[^>]*>|<br\b(?!\s*\/?>)[^>]*>/iu.test(content)
}
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import * as roundTrip from './markdown-round-trip'
import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants'
import {
getMarkdownRichModeEligibility,
@@ -22,6 +23,53 @@ describe('getMarkdownRichModeUnsupportedMessage', () => {
expect(getMarkdownRichModeUnsupportedMessage('Before <span>hi</span> after\n')).toBeNull()
})
it.each(['', ' open', ' open="open"', " data-orca-toggle='heading-2' open"])(
'allows editable details blocks with attributes %s',
(attributes) => {
const content = `<details${attributes}>\n<summary>Toggle</summary>\n\nBody\n\n</details>\n`
expect(getMarkdownRichModeUnsupportedMessage(content)).toBeNull()
}
)
it('allows nested plain details blocks', () => {
const inner = '<details>\n<summary>Inner</summary>\n\nBody\n\n</details>'
const content = `<details open>\n<summary>Outer</summary>\n\n${inner}\n\n</details>\n`
expect(getMarkdownRichModeUnsupportedMessage(content)).toBeNull()
})
it('checks mixed editable and passthrough details blocks in source order', () => {
const content = [
'<details>\n<summary>Editable</summary>\n\nBody\n\n</details>',
'<details>\n<summary><span>Passthrough</span></summary>\n\nBody\n\n</details>',
'<details class="orca-details" open>\n<summary>Authored</summary>\n\nBody\n\n</details>'
].join('\n\n')
expect(getMarkdownRichModeUnsupportedMessage(content)).toBeNull()
})
it.each([' id="keep"', ' class="custom"', ' data-orca-toggle="heading-6"', ' open'])(
'rejects a details round trip that loses attributes %s',
(attributes) => {
const content = `<details${attributes}>\n<summary>Toggle</summary>\n\nBody\n\n</details>`
vi.spyOn(roundTrip, 'getRichMarkdownRoundTripOutput').mockReturnValue(
'<details class="orca-details">\n<summary>Toggle</summary>\n\nBody\n\n</details>'
)
expect(getMarkdownRichModeUnsupportedMessage(content)).not.toBeNull()
}
)
it('still rejects unrelated HTML lost alongside a normalized details tag', () => {
const content = '<details>\n<summary>Toggle</summary>\n\nBody\n\n</details>\n<span>Tail</span>'
vi.spyOn(roundTrip, 'getRichMarkdownRoundTripOutput').mockReturnValue(
'<details class="orca-details">\n<summary>Toggle</summary>\n\nBody\n\n</details>\nTail'
)
expect(getMarkdownRichModeUnsupportedMessage(content)).not.toBeNull()
})
it('allows markdown autolinks wrapped in angle brackets', () => {
expect(
getMarkdownRichModeUnsupportedMessage('See <https://example.com/docs> for details.\n')
@@ -1,4 +1,5 @@
import { defaultSchema } from 'rehype-sanitize'
import { normalizeDetailsOpeningTag } from './details-markdown-html'
import { getRichMarkdownRoundTripOutput } from './markdown-round-trip'
import { extractFrontMatter } from './markdown-frontmatter'
import { exceedsMarkdownRichModeSizeLimit } from './markdown-rich-size-limit'
@@ -215,11 +216,18 @@ function stripMarkdownCode(content: string): string {
function preservesEmbeddedHtml(contentWithoutCode: string, roundTripOutput: string): boolean {
let searchIndex = 0
return forEachEmbeddedHtmlFragment(contentWithoutCode, (fragment) => {
const foundIndex = roundTripOutput.indexOf(fragment, searchIndex)
const normalized = normalizeDetailsOpeningTag(fragment)
const exactIndex = roundTripOutput.indexOf(fragment, searchIndex)
// Details serialization adds Orca's class and canonicalizes supported attributes.
const normalizedIndex =
normalized === fragment ? -1 : roundTripOutput.indexOf(normalized, searchIndex)
const useNormalized =
normalizedIndex !== -1 && (exactIndex === -1 || normalizedIndex < exactIndex)
const foundIndex = useNormalized ? normalizedIndex : exactIndex
if (foundIndex === -1) {
return false
}
searchIndex = foundIndex + fragment.length
searchIndex = foundIndex + (useNormalized ? normalized.length : fragment.length)
return true
})
}
@@ -212,6 +212,15 @@ describe('rich markdown round trip', () => {
expect(roundTripMarkdown(input)).toBe(input.trimEnd())
})
it.each(['class="ORCA-DETAILS"', "CLASS='Orca-Details'", 'Class=ORCA-DETAILS'])(
'preserves details with case-sensitive %s as passthrough html',
(attributes) => {
const input = `<details ${attributes}><summary>Toggle</summary><p>Body</p></details>`
expect(roundTripMarkdown(input)).toBe(input)
}
)
it('preserves details blocks with unsupported attributes as passthrough html', () => {
const input =
'<details id="x"><summary class="s">Toggle</summary><p data-x="1">Body</p></details>\n'
+30
View File
@@ -26,6 +26,36 @@ test.describe('Markdown nested toggle regression', () => {
await waitForActiveWorktree(orcaPage)
})
test('a plain details block opens as an editable toggle', async ({ orcaPage }, testInfo) => {
const context = await getActiveWorktreeContext(orcaPage)
let filePath: string | null = null
try {
filePath = await createMarkdownFixture(
context,
NESTED_TOGGLE_FIXTURE_DIRECTORY,
'plain-details',
testInfo.workerIndex,
'<details>\n<summary>Toggle</summary>\n\nBody\n\n</details>\n'
)
await openMarkdownFixture(orcaPage, context, filePath)
const editor = await waitForRichMarkdownEditor(orcaPage)
const toggle = editor.locator('[data-type="details"]')
await expect(toggle).toHaveCount(1)
await expect(toggle.locator('summary')).toHaveText('Toggle')
await expect(editor.locator('[data-raw-markdown-html-block]')).toHaveCount(0)
const screenshotPath = testInfo.outputPath('plain-details-rich-editor.png')
await orcaPage.screenshot({ path: screenshotPath })
await testInfo.attach('plain-details-rich-editor', {
path: screenshotPath,
contentType: 'image/png'
})
} finally {
await cleanupMarkdownFixture(filePath)
}
})
test('a nested toggle on disk reopens as editable toggles', async ({ orcaPage }, testInfo) => {
const context = await getActiveWorktreeContext(orcaPage)
let filePath: string | null = null