diff --git a/src/renderer/src/components/editor/details-markdown-html.test.ts b/src/renderer/src/components/editor/details-markdown-html.test.ts index 85f8f332cfe..7bedd8960ed 100644 --- a/src/renderer/src/components/editor/details-markdown-html.test.ts +++ b/src/renderer/src/components/editor/details-markdown-html.test.ts @@ -3,6 +3,7 @@ import { extractDetailsSummaryHtml, isEditableDetailsHtmlBlock, matchDetailsHtmlBlock, + normalizeDetailsOpeningTag, parseDetailsAttributes, parseToggleHeadingVariant, type DetailsHtmlBlock @@ -26,6 +27,45 @@ afterEach(() => { }) describe('details markdown html', () => { + it.each([ + ['
', '
'], + ['
', '
'], + ['
', '
'], + ["
", '
'], + ['
', '
'], + [ + "
", + '
' + ] + ])('normalizes supported opening tag %s like the serializer', (input, expected) => { + expect(normalizeDetailsOpeningTag(input)).toBe(expected) + }) + + it.each([ + '
', + '
', + '
', + "
", + '
', + '
', + '
', + '', + '
', + '', + '' + ])('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(`
ToggleBody
`) + ).toBe(false) + } + ) + it('extracts leading summary html without regex capture', () => { const matchSpy = vi.spyOn(String.prototype, 'match') const inner = `\n${'Heading line\n'.repeat(1_000)}

Body

` diff --git a/src/renderer/src/components/editor/details-markdown-html.ts b/src/renderer/src/components/editor/details-markdown-html.ts index 964c08f35d6..9b826012a3f 100644 --- a/src/renderer/src/components/editor/details-markdown-html.ts +++ b/src/renderer/src/components/editor/details-markdown-html.ts @@ -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(/^]*)?>$/i) + const attributes = match?.[1] ?? '' + if (!match || !hasOnlySupportedDetailsAttributes(attributes)) { + return fragment + } + return `
` +} + function hasOnlyPlainParagraphAndBreakTags(content: string): boolean { return !/)[^>]*>|)[^>]*>/iu.test(content) } diff --git a/src/renderer/src/components/editor/markdown-rich-mode.test.ts b/src/renderer/src/components/editor/markdown-rich-mode.test.ts index 25f2e3bd869..85d3e2fbddd 100644 --- a/src/renderer/src/components/editor/markdown-rich-mode.test.ts +++ b/src/renderer/src/components/editor/markdown-rich-mode.test.ts @@ -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 hi after\n')).toBeNull() }) + it.each(['', ' open', ' open="open"', " data-orca-toggle='heading-2' open"])( + 'allows editable details blocks with attributes %s', + (attributes) => { + const content = `\nToggle\n\nBody\n\n
\n` + + expect(getMarkdownRichModeUnsupportedMessage(content)).toBeNull() + } + ) + + it('allows nested plain details blocks', () => { + const inner = '
\nInner\n\nBody\n\n
' + const content = `
\nOuter\n\n${inner}\n\n
\n` + + expect(getMarkdownRichModeUnsupportedMessage(content)).toBeNull() + }) + + it('checks mixed editable and passthrough details blocks in source order', () => { + const content = [ + '
\nEditable\n\nBody\n\n
', + '
\nPassthrough\n\nBody\n\n
', + '
\nAuthored\n\nBody\n\n
' + ].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 = `\nToggle\n\nBody\n\n
` + vi.spyOn(roundTrip, 'getRichMarkdownRoundTripOutput').mockReturnValue( + '
\nToggle\n\nBody\n\n
' + ) + + expect(getMarkdownRichModeUnsupportedMessage(content)).not.toBeNull() + } + ) + + it('still rejects unrelated HTML lost alongside a normalized details tag', () => { + const content = '
\nToggle\n\nBody\n\n
\nTail' + vi.spyOn(roundTrip, 'getRichMarkdownRoundTripOutput').mockReturnValue( + '
\nToggle\n\nBody\n\n
\nTail' + ) + + expect(getMarkdownRichModeUnsupportedMessage(content)).not.toBeNull() + }) + it('allows markdown autolinks wrapped in angle brackets', () => { expect( getMarkdownRichModeUnsupportedMessage('See for details.\n') diff --git a/src/renderer/src/components/editor/markdown-rich-mode.ts b/src/renderer/src/components/editor/markdown-rich-mode.ts index 4849380f8fe..a499acd02dd 100644 --- a/src/renderer/src/components/editor/markdown-rich-mode.ts +++ b/src/renderer/src/components/editor/markdown-rich-mode.ts @@ -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 }) } diff --git a/src/renderer/src/components/editor/markdown-round-trip.test.ts b/src/renderer/src/components/editor/markdown-round-trip.test.ts index 8804cc06125..a6e39e78e4b 100644 --- a/src/renderer/src/components/editor/markdown-round-trip.test.ts +++ b/src/renderer/src/components/editor/markdown-round-trip.test.ts @@ -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 = `
Toggle

Body

` + + expect(roundTripMarkdown(input)).toBe(input) + } + ) + it('preserves details blocks with unsupported attributes as passthrough html', () => { const input = '
Toggle

Body

\n' diff --git a/tests/e2e/markdown-nested-toggle.spec.ts b/tests/e2e/markdown-nested-toggle.spec.ts index 537e6b65775..94eb7f2ef73 100644 --- a/tests/e2e/markdown-nested-toggle.spec.ts +++ b/tests/e2e/markdown-nested-toggle.spec.ts @@ -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, + '
\nToggle\n\nBody\n\n
\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