perf: reuse Markdown fence scans across sibling toggles (#26324)

This commit is contained in:
Neil
2026-10-07 22:53:01 -07:00
committed by GitHub
parent 665fbc4a83
commit 10a2a6c2af
3 changed files with 81 additions and 4 deletions
@@ -92,6 +92,14 @@ export function renderDetailsAttributes(attrs: Record<string, unknown> | undefin
return attributes.join(' ')
}
export function createDetailsMatcher(content: string): (start: number) => DetailsHtmlBlock | null {
let fenceRanges: MarkdownFenceRanges | undefined
return (start) =>
/^<details\b[^>]*>/i.test(content.slice(start))
? matchDetailsHtmlBlock(content, start, (fenceRanges ??= getMarkdownFenceRanges(content)))
: null
}
export function matchDetailsHtmlBlock(
content: string,
start: number,
@@ -0,0 +1,70 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
import * as fenceScanner from './markdown-fence-scanner'
vi.mock('./markdown-fence-scanner', { spy: true })
const key = '0123456789abcdef0123456789abcdef'
afterEach(() => vi.restoreAllMocks())
describe('raw Markdown details fence scanning', () => {
it('scans the document once for sibling toggles', () => {
const content = Array.from(
{ length: 50 },
(_, index) => `<details><summary>Section ${index}</summary>\n\nBody ${index}\n\n</details>`
).join('\n\n')
const scanFences = vi.mocked(fenceScanner.getMarkdownFenceRanges)
scanFences.mockClear()
expect(encodeRawMarkdownHtmlForRichEditor(content, createRichMarkdownEditorCodec(key))).toBe(
content
)
expect(scanFences).toHaveBeenCalledTimes(1)
})
it.each([
'Plain text\n\nMore plain text',
'<details',
'<details open\nStill no opening tag terminator',
'```html\n<details><summary>Code</summary>\n\nBody\n\n</details>\n```'
])('does not scan fences when no opening tag is matched: %j', (content) => {
const scanFences = vi.mocked(fenceScanner.getMarkdownFenceRanges)
scanFences.mockClear()
expect(encodeRawMarkdownHtmlForRichEditor(content, createRichMarkdownEditorCodec(key))).toBe(
content
)
expect(scanFences).toHaveBeenCalledTimes(0)
})
it('preserves nested and mixed-case editable toggles', () => {
const content =
'<DeTaIlS open><SuMmArY>Outer</SuMmArY>\n\n<details><summary>Inner</summary>\n\nBody\n\n</details>\n\n</DeTaIlS>'
expect(encodeRawMarkdownHtmlForRichEditor(content, createRichMarkdownEditorCodec(key))).toBe(
content
)
})
it('uses each document’s own fences when the codec is reused', () => {
const codec = createRichMarkdownEditorCodec(key)
const first = '<details><summary>First</summary>\n\nBody\n\n</details>'
const second =
'<details><summary>Second</summary>\n\n```html\n</details>\n```\n\nBody\n\n</details>'
expect(encodeRawMarkdownHtmlForRichEditor(first, codec)).toBe(first)
expect(encodeRawMarkdownHtmlForRichEditor(second, codec)).toBe(
codec.transport.create('block-html', second)
)
expect(encodeRawMarkdownHtmlForRichEditor(first, codec)).toBe(first)
})
it('preserves fenced code before a non-editable sibling', () => {
const codec = createRichMarkdownEditorCodec(key)
const editable = '<details><summary>Editable</summary>\n\nBody\n\n</details>'
const raw = '<details class="custom"><summary>Raw</summary>\n\nBody\n\n</details>'
const prefix = '~~~html\n<details><summary>Code</summary></details>\n~~~\n\n'
expect(encodeRawMarkdownHtmlForRichEditor(`${prefix}${editable}\n\n${raw}`, codec)).toBe(
`${prefix}${editable}\n\n${codec.transport.create('block-html', raw)}`
)
})
})
@@ -1,10 +1,9 @@
import { isEditableDetailsHtmlBlock, matchDetailsHtmlBlock } from './details-markdown-html'
import { createDetailsMatcher, isEditableDetailsHtmlBlock } from './details-markdown-html'
import { formatMarkdownDocLinkBody, parseMarkdownDocLink } from './markdown-doc-links'
import { normalizeMarkdownReferenceLinks } from './markdown-reference-link-normalization'
import type { RichMarkdownEditorCodec } from './rich-markdown-source-transport'
import { isReservedRichMarkdownTransportBody } from './rich-markdown-source-transport'
import { matchHtmlSuperscriptLinkSource } from './rich-markdown-html-superscript-link-source'
import { getMarkdownFenceRanges } from './markdown-fence-scanner'
const INLINE_HTML_PATTERN = /^<!--[\s\S]*?-->|^<\/?[A-Za-z][\w.:-]*(?:\s[^<>]*?)?\/?>/
@@ -46,7 +45,7 @@ export function encodeRawMarkdownHtmlForRichEditor(
{ htmlSuperscriptLinks = false }: { htmlSuperscriptLinks?: boolean } = {}
): string {
const normalizedContent = normalizeMarkdownReferenceLinks(content)
const fenceRanges = getMarkdownFenceRanges(normalizedContent)
const matchDetails = createDetailsMatcher(normalizedContent)
const lastCommentClose = normalizedContent.lastIndexOf('-->')
const { transport } = codec
let index = 0
@@ -123,7 +122,7 @@ export function encodeRawMarkdownHtmlForRichEditor(
}
if (isLineStart) {
const detailsHtml = matchDetailsHtmlBlock(normalizedContent, index, fenceRanges)
const detailsHtml = matchDetails(index)
if (detailsHtml && isEditableDetailsHtmlBlock(detailsHtml)) {
// Why: <details>/<summary> is an editable rich-mode node; raw passthrough
// would make toggle blocks reopen as inert HTML instead.