mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
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:
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user