From 30bb30e25c3d915451d6d7ba372a5544d3fbcf40 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Wed, 28 Oct 2026 12:56:11 -0700
Subject: [PATCH] Long chat replies stream with less stutter: parse each block
once (#34821)
* Reduce stuttering while long chat replies stream
* Keep streaming markdown pipeline within lint limits
* Type the streaming parser meter without a cast
* Type the streamed markdown test reveal state
* Preserve markdown render domains when configured syntax cannot reuse discovery
---
.../native-chat/NativeChatMarkdown.tsx | 3 +
.../NativeChatMarkdown.visual.test.tsx | 20 +-
.../native-chat-visual-markdown-extension.tsx | 2 +
.../CommentMarkdown.parse-reuse.test.tsx | 226 ++++++++
.../CommentMarkdown.parse-validity.test.tsx | 483 ++++++++++++++++++
.../components/sidebar/CommentMarkdown.tsx | 70 +--
.../comment-markdown-block-pipeline.tsx | 103 ++++
.../markdown-parse-meter.test-fixture.ts | 51 ++
...wn-top-level-blocks-legacy.test-fixture.ts | 103 ++++
.../sidebar/markdown-top-level-blocks.ts | 111 +++-
10 files changed, 1098 insertions(+), 74 deletions(-)
create mode 100644 src/renderer/src/components/sidebar/CommentMarkdown.parse-reuse.test.tsx
create mode 100644 src/renderer/src/components/sidebar/CommentMarkdown.parse-validity.test.tsx
create mode 100644 src/renderer/src/components/sidebar/comment-markdown-block-pipeline.tsx
create mode 100644 src/renderer/src/components/sidebar/markdown-parse-meter.test-fixture.ts
create mode 100644 src/renderer/src/components/sidebar/markdown-top-level-blocks-legacy.test-fixture.ts
diff --git a/src/renderer/src/components/native-chat/NativeChatMarkdown.tsx b/src/renderer/src/components/native-chat/NativeChatMarkdown.tsx
index 96682e6cd69..0596b2d7c44 100644
--- a/src/renderer/src/components/native-chat/NativeChatMarkdown.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatMarkdown.tsx
@@ -54,6 +54,9 @@ function withFileDestinations(
): CommentMarkdownExtension {
return {
remarkPlugins: [...(visualExtension?.remarkPlugins ?? []), remarkNativeChatFileDestinations],
+ extraFlowSyntaxMarkers: visualExtension?.remarkPlugins.length
+ ? visualExtension.extraFlowSyntaxMarkers
+ : [],
sanitizeAttributes: visualExtension?.sanitizeAttributes ?? {},
components: visualExtension?.components ?? {}
}
diff --git a/src/renderer/src/components/native-chat/NativeChatMarkdown.visual.test.tsx b/src/renderer/src/components/native-chat/NativeChatMarkdown.visual.test.tsx
index 1e71b1963c7..8743f4efab4 100644
--- a/src/renderer/src/components/native-chat/NativeChatMarkdown.visual.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatMarkdown.visual.test.tsx
@@ -104,7 +104,12 @@ describe('NativeChatMarkdown visuals', () => {
it('keeps a mounted visual frame while the rest of the reply streams in', async () => {
const { container, rerender } = render(
withOwner(
-
+
)
)
await waitFor(() => expect(container.querySelector('iframe')).not.toBeNull())
@@ -112,8 +117,19 @@ describe('NativeChatMarkdown visuals', () => {
rerender(
withOwner(
+ )
+ )
+ rerender(
+ withOwner(
+
)
diff --git a/src/renderer/src/components/native-chat/native-chat-visual-markdown-extension.tsx b/src/renderer/src/components/native-chat/native-chat-visual-markdown-extension.tsx
index ec9d677e577..da3b54ae96f 100644
--- a/src/renderer/src/components/native-chat/native-chat-visual-markdown-extension.tsx
+++ b/src/renderer/src/components/native-chat/native-chat-visual-markdown-extension.tsx
@@ -3,6 +3,7 @@ import type { ExtraProps } from 'react-markdown'
import type { CommentMarkdownExtension } from '@/components/sidebar/CommentMarkdown'
import {
NATIVE_CHAT_VISUAL_TITLE_MAX_LENGTH,
+ NATIVE_CHAT_VISUAL_DIRECTIVE_MARKER,
isNativeChatVisualFileName
} from '../../../../shared/native-chat/native-chat-visual-directive'
import { NativeChatInlineVisual } from './NativeChatInlineVisual'
@@ -55,6 +56,7 @@ function createVisualExtension(
}
return {
remarkPlugins: [[remarkNativeChatVisuals, nonce]],
+ extraFlowSyntaxMarkers: [NATIVE_CHAT_VISUAL_DIRECTIVE_MARKER],
sanitizeAttributes: { div: [...NATIVE_CHAT_VISUAL_PLACEHOLDER_PROPERTIES] },
components: { div: VisualPlaceholder }
}
diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.parse-reuse.test.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.parse-reuse.test.tsx
new file mode 100644
index 00000000000..90007223007
--- /dev/null
+++ b/src/renderer/src/components/sidebar/CommentMarkdown.parse-reuse.test.tsx
@@ -0,0 +1,226 @@
+// @vitest-environment happy-dom
+import React from 'react'
+import { cleanup, render, waitFor } from '@testing-library/react'
+import { afterEach, beforeAll, expect, it, vi } from 'vitest'
+import type { Processor } from 'unified'
+import type remarkParsePlugin from 'remark-parse'
+import type * as ReactMarkdownModule from 'react-markdown'
+import type * as MarkdownBlocksModule from './markdown-top-level-blocks'
+import CommentMarkdown from './CommentMarkdown'
+import {
+ advanceNativeChatText,
+ receiveNativeChatText,
+ type NativeChatTextReveal
+} from '../../../../shared/native-chat/transcript/native-chat-text-reveal'
+
+const meter = vi.hoisted((): { legacy: boolean; sources: string[] } => ({
+ legacy: false,
+ sources: []
+}))
+vi.mock('remark-parse', async (original) => {
+ const { default: plugin } = await original<{ default: typeof remarkParsePlugin }>()
+ return {
+ default: function (this: Processor) {
+ plugin.call(this)
+ const parse = this.parser!
+ this.parser = (source, file) => {
+ meter.sources.push(source)
+ return parse(source, file)
+ }
+ }
+ }
+})
+vi.mock('react-markdown', async (original) => {
+ const real = await original()
+ function measureRenderParser(this: Processor): void {
+ const parse = this.parser!
+ this.parser = (source, file) => {
+ meter.sources.push(source)
+ return parse(source, file)
+ }
+ }
+ return {
+ ...real,
+ default: (props: React.ComponentProps) =>
+ real.default({
+ ...props,
+ remarkPlugins: [measureRenderParser, ...(props.remarkPlugins ?? [])]
+ })
+ }
+})
+vi.mock('./markdown-top-level-blocks', async (original) => {
+ const current = await original()
+ const legacy = await import('./markdown-top-level-blocks-legacy.test-fixture')
+ return {
+ ...current,
+ splitMarkdownTopLevelBlocks: (
+ ...args: Parameters
+ ) => {
+ const result = meter.legacy
+ ? legacy.splitMarkdownTopLevelBlocks(args[0], args[1], args[2])
+ : current.splitMarkdownTopLevelBlocks(...args)
+ return result
+ }
+ }
+})
+vi.mock('./CommentMermaidBlock', () => ({ default: () => null }))
+
+beforeAll(async () => {
+ const warm = render()
+ await waitFor(() => expect(warm.container.querySelector('.katex')).not.toBeNull())
+ warm.unmount()
+})
+afterEach(() => {
+ cleanup()
+ meter.legacy = false
+ meter.sources = []
+})
+
+// The production reveal cadence supplies exactly the same prefixes to both renderers.
+function sequence(full: string, start: number): string[] {
+ let state: NativeChatTextReveal = { source: full.slice(0, start), shown: start, arrivals: [] }
+ let nextArrival = 0
+ const result = [state.source]
+ for (let frame = 0; frame <= 84; frame++) {
+ const now = (frame * 1000) / 60
+ while (nextArrival <= Math.min(now, 1008)) {
+ const end = Math.min(
+ full.length,
+ start + Math.ceil(((full.length - start) * (nextArrival + 48)) / 1056)
+ )
+ state = receiveNativeChatText(state, full.slice(0, end), true, false, nextArrival)
+ nextArrival += 48
+ }
+ state = advanceNativeChatText(state, now)
+ const text = state.source.slice(0, state.shown)
+ if (text !== result.at(-1)) {
+ result.push(text)
+ }
+ }
+ if (result.at(-1) !== full) {
+ result.push(full)
+ }
+ return result
+}
+
+const prose =
+ 'The implementation reads **stable values** and keeps the same behavior for every user. '.repeat(
+ 650
+ )
+const fence = `\`\`\`text\n${'const result = readCurrentValue(input);\n'.repeat(1400)}\`\`\``
+const list = Array.from(
+ { length: 650 },
+ (_, i) => `- Item ${i}: preserve **current behavior** and report the measured result.\n`
+).join('')
+const code = ({ children, sourceLine }: { children?: React.ReactNode; sourceLine?: number }) => (
+ {children}
+)
+const exists = () => true
+
+it.each([
+ ['short', prose.slice(0, 800), 0],
+ ['paragraph', prose.slice(0, 50000), 40000],
+ ['fence', fence, 40000],
+ ['list', list, 35000],
+ ['settled fence and tail', `${fence}\n\n${prose.slice(0, 800)}`, fence.length + 2],
+ [
+ 'backward boundaries',
+ 'Title\n===\n\n- first\n\n- second\n\n continued\n\n| a | b |\n| - | - |\n| 1 | 2 |\n',
+ 0
+ ],
+ [
+ 'global constructs',
+ 'See [docs][ref].\n\nMore.[^1]\n\n[ref]: https://example.com\n\n[^1]: note\n\n\n\n**hidden**\n\n \n\ntext',
+ 0
+ ],
+ [
+ 'math and repair',
+ 'Before **bold** and [link](https://example.com).\n\n$x^2$\n\n$$\nx+y\n$$\n\n\\[z\\]\n\n```math\nx+1\n```\n\n~~~math\ny\n~~~\n\n$unfinished **tail',
+ 0
+ ],
+ [
+ 'positions and links',
+ '\n\n ```ts\nline one\nline two\n ```\n\n ~~~text\nother\n ~~~\n\nEdited src/a.ts and [file](file:///tmp/a.ts).\n\n中文**标点。**后续\n',
+ 0
+ ],
+ [
+ 'indented blocks',
+ 'Intro\n\n ```text\n value\n ```\n\n - one\n\n nested code\n\n > quote\n > ```text\n > value\n > ```\n\nnext',
+ 0
+ ],
+ ['internal BOM', 'one\n\n\uFEFFnext\n\n\uFEFF```text\ncode\n```\n\ntail', 0],
+ [
+ 'line endings',
+ 'one\r\n\r\n > quote\r\n\r\n```text\r\ncode\r\n```\r\n\r\nlast\uFEFF word\r\n',
+ 0
+ ]
+])(
+ 'preserves exact DOM and copy text at every reveal update: %s',
+ (_name, full, start) => {
+ const old = render()
+ const next = render()
+ const props = {
+ math: true,
+ variant: 'document',
+ fadeWords: true,
+ unknownHtmlAsText: true,
+ allowFileUriLinks: true,
+ fileLinkExists: exists,
+ renderCodeBlock: code
+ } as const
+ const prefixes = sequence(full, start)
+ for (const [index, content] of [...prefixes, full].entries()) {
+ const growing = index < prefixes.length
+ meter.legacy = true
+ old.rerender()
+ meter.legacy = false
+ next.rerender()
+ expect(next.container.innerHTML, `at ${content.length} characters`).toBe(
+ old.container.innerHTML
+ )
+ expect(next.container.textContent).toBe(old.container.textContent)
+ }
+ },
+ 55000
+)
+
+it('uses one actual parse for discovery and rendering, then skips a completed fence', () => {
+ const source = `${fence}\n\nNext`
+ meter.sources = []
+ const view = render()
+ expect(meter.sources).toEqual([source])
+ meter.sources = []
+ view.rerender()
+ expect(meter.sources).toEqual(['Next words'])
+ view.unmount()
+
+ const staticView = render()
+ meter.sources = []
+ staticView.rerender()
+ expect(meter.sources).toEqual([])
+ staticView.unmount()
+
+ meter.legacy = true
+ meter.sources = []
+ render()
+ expect(meter.sources.reduce((count, text) => count + text.length, 0)).toBeGreaterThan(
+ source.length
+ )
+})
+
+it('preserves the current cut when the link policy changes', () => {
+ const full =
+ 'Intro\n\n ```text\n value\n ```\n\n - one\n\n nested code\n\n > quote\n '
+ const snapshots: string[] = []
+ for (const legacy of [true, false]) {
+ meter.legacy = legacy
+ const view = render()
+ for (const content of sequence(full, 0)) {
+ view.rerender()
+ }
+ view.rerender( false} />)
+ snapshots.push(view.container.innerHTML)
+ view.unmount()
+ }
+ expect(snapshots[1]).toBe(snapshots[0])
+})
diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.parse-validity.test.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.parse-validity.test.tsx
new file mode 100644
index 00000000000..355f3bef667
--- /dev/null
+++ b/src/renderer/src/components/sidebar/CommentMarkdown.parse-validity.test.tsx
@@ -0,0 +1,483 @@
+// @vitest-environment happy-dom
+import React from 'react'
+import { act, cleanup, render } from '@testing-library/react'
+import { afterEach, expect, it, vi } from 'vitest'
+import type { Processor } from 'unified'
+import type remarkParsePlugin from 'remark-parse'
+import type * as ReactMarkdownModule from 'react-markdown'
+import type * as BlocksModule from './markdown-top-level-blocks'
+import type { Root } from 'mdast'
+import CommentMarkdown, { type CommentMarkdownExtension } from './CommentMarkdown'
+import { createParseWork, meterPositions, nodeCount } from './markdown-parse-meter.test-fixture'
+import { NativeChatMarkdown } from '../native-chat/NativeChatMarkdown'
+import { NativeChatVisualOwnerContext } from '../native-chat/native-chat-visual-owner'
+import { NATIVE_CHAT_VISUAL_DIRECTIVE_MARKER } from '../../../../shared/native-chat/native-chat-visual-directive'
+import { remarkNativeChatVisuals } from '../native-chat/native-chat-visual-markdown-syntax'
+
+const meter = vi.hoisted(
+ (): { legacy: boolean; work: ReturnType | null } => ({
+ legacy: false,
+ work: null
+ })
+)
+vi.mock('remark-parse', async (original) => {
+ const { default: plugin } = await original<{ default: typeof remarkParsePlugin }>()
+ return {
+ default: function (this: Processor) {
+ plugin.call(this)
+ const parse = this.parser!
+ this.parser = (source, file) => {
+ meter.work?.discovery.push(source)
+ const tree = parse(source, file)
+ if (isRoot(tree)) {
+ meterPositions(tree, () => {
+ if (meter.work) {
+ meter.work.shifts++
+ }
+ })
+ }
+ return tree
+ }
+ }
+ }
+})
+vi.mock('react-markdown', async (original) => {
+ const real = await original()
+ function measureOriginal(this: Processor): void {
+ const parse = this.parser!
+ this.parser = (source, file) => {
+ meter.work?.render.push(source)
+ return parse(source, file)
+ }
+ }
+ return {
+ ...real,
+ default: (props: React.ComponentProps) =>
+ real.default({
+ ...props,
+ remarkPlugins: [measureOriginal, ...(props.remarkPlugins ?? [])]
+ })
+ }
+})
+vi.mock('./markdown-top-level-blocks', async (original) => {
+ const current = await original()
+ const legacy = await import('./markdown-top-level-blocks-legacy.test-fixture')
+ return {
+ ...current,
+ splitMarkdownTopLevelBlocks: (
+ ...args: Parameters
+ ) => {
+ const result = meter.legacy
+ ? legacy.splitMarkdownTopLevelBlocks(args[0], args[1], args[2])
+ : current.splitMarkdownTopLevelBlocks(...args)
+ if (meter.work) {
+ meter.work.blocks = Math.max(meter.work.blocks, result.blocks.length)
+ }
+ return result
+ }
+ }
+})
+vi.mock('../native-chat/NativeChatInlineVisual', () => ({
+ NativeChatInlineVisual: ({ file }: { file: string }) =>
+}))
+vi.mock('../native-chat/use-native-chat-file-link-existence', () => ({
+ useNativeChatFileLinkExists: () => undefined
+}))
+vi.mock('./CommentMermaidBlock', () => ({ default: () => null }))
+
+afterEach(() => {
+ cleanup()
+ meter.legacy = false
+ meter.work = null
+ vi.restoreAllMocks()
+ vi.useRealTimers()
+})
+function isRoot(value: unknown): value is Root {
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ 'type' in value &&
+ value.type === 'root' &&
+ 'children' in value &&
+ Array.isArray(value.children)
+ )
+}
+const owner = { target: { kind: 'local' }, sessionId: 's', worktreeId: 'w' } as const
+const code = ({ children, sourceLine }: { children?: React.ReactNode; sourceLine?: number }) => (
+ {children}
+)
+function native(
+ content: string,
+ growing = true,
+ enabled = true,
+ variant: 'document' | undefined = undefined
+) {
+ return (
+
+
+
+ )
+}
+const directive = (i = 0) => `::orca-visual{file="v${i}.html"}`
+const prefix = '```text\nsettled\n```\n\nNext'
+
+it.each([
+ [
+ 'adjacent',
+ `${prefix}\n\n${Array.from({ length: 10 }, (_, i) => directive(i)).join('\n')}\n\nend`,
+ 8
+ ],
+ ['separated', `${Array.from({ length: 10 }, (_, i) => directive(i)).join('\n\n')}\n\nend`, 10],
+ ['lazy list', `- a\n ${directive()}\nfoo\n\nbar`, 0],
+ ['lazy quote', `> a\n${directive()}\nfoo\n\nbar`, 1],
+ ['nested', `- item\n\n ${directive()}\n\n> ${directive(1)}\n\nend`, 0],
+ ['fenced marker', `~~~text\n${directive()}\n~~~\n\nend`, 0],
+ ['title CRLF', '::orca-visual{file="v0.html" title="Title"}\r\n\r\nend', 1],
+ [
+ 'invalid escaped overlength',
+ `\\${directive()}\n\n::orca-visual{file="../bad.html"}\n\n::orca-visual{file="v.html" title="${'x'.repeat(520)}"}\n\nend`,
+ 0
+ ],
+ [
+ 'chart prose math CJK',
+ `${directive()}\n\n中文**标点。** $x+1$ and **tail\n\n[ref]: https://example.com`,
+ 1
+ ],
+ [
+ 'BOM and sliced context',
+ `one\n\n\uFEFFnext\n\n ~~~text\n value\n ~~~\n\n${directive()}\n\nend`,
+ 1
+ ]
+])(
+ 'retains exact native DOM/text at every filtered prefix and completion: %s',
+ (_name, full, mounts) => {
+ const old = render(native(''))
+ const next = render(native(''))
+ for (let end = 0; end <= full.length + 1; end++) {
+ const content = full.slice(0, end)
+ meter.legacy = true
+ old.rerender(native(content, end <= full.length))
+ meter.legacy = false
+ next.rerender(native(content, end <= full.length))
+ expect(next.container.innerHTML, `prefix ${end}`).toBe(old.container.innerHTML)
+ expect(next.container.textContent).toBe(old.container.textContent)
+ }
+ expect(next.container.querySelectorAll('[data-visual-file]')).toHaveLength(mounts)
+ if (_name === 'adjacent') {
+ expect(next.container.textContent).toContain(directive(9))
+ }
+ },
+ 55000
+)
+
+it('preserves historical cuts across config, replacement, completion, re-entry and remount', () => {
+ const snapshots: string[][] = []
+ for (const legacy of [true, false]) {
+ meter.legacy = legacy
+ let view = render(native(prefix, true, false))
+ const results: string[] = []
+ const steps: [string, boolean, boolean, 'document' | undefined][] = [
+ [prefix, true, true, undefined],
+ [prefix, true, false, undefined],
+ [prefix, true, true, 'document'],
+ [`${prefix}\n\n${directive()}\n\nMore`, true, true, 'document'],
+ [`${prefix}\n\n${directive()}\n\nMore`, false, true, 'document'],
+ [`${prefix}\n\n${directive()}\n\nMore words`, true, true, 'document'],
+ ['replacement', true, true, undefined],
+ [directive(), true, true, undefined],
+ [directive(), false, true, undefined],
+ [`${prefix}\n\n::orca-visual{file="v`, true, true, undefined],
+ [`${prefix}\n\n::orca-visual{file="v`, false, true, undefined],
+ ['', true, true, undefined],
+ ['one\n\n\uFEFFtail', true, true, undefined],
+ [prefix, true, true, undefined]
+ ]
+ for (const step of steps) {
+ view.rerender(native(...step))
+ results.push(view.container.innerHTML)
+ }
+ view.unmount()
+ view = render(native(`${directive()}\n\nend`))
+ results.push(view.container.innerHTML)
+ view.unmount()
+ view = render(native(`${directive()}\n\nend`, false))
+ results.push(view.container.innerHTML)
+ view.unmount()
+ snapshots.push(results)
+ }
+ expect(snapshots[1]).toEqual(snapshots[0])
+})
+
+it('defaults unknown ordinary-node syntax to legacy cuts, but permits image-only plugins', () => {
+ function extraSyntax(this: Processor): void {
+ const parse = this.parser!
+ this.parser = (source, file) => parse(source.replaceAll(' / ', '\n\n\n'), file)
+ }
+ const unknown: CommentMarkdownExtension = {
+ remarkPlugins: [extraSyntax],
+ sanitizeAttributes: {},
+ components: {}
+ }
+ const outputs: string[] = []
+ for (const legacy of [true, false]) {
+ meter.legacy = legacy
+ const view = render()
+ outputs.push(view.container.innerHTML)
+ view.unmount()
+ }
+ expect(outputs[1]).toBe(outputs[0])
+ meter.legacy = false
+ meter.work = createParseWork()
+ render(
+
+ )
+ expect(meter.work.discovery).toEqual([prefix])
+ expect(meter.work.render).toEqual([])
+})
+
+it('keeps selected fade nodes and their clocks through a declined tail and completion', () => {
+ vi.useFakeTimers()
+ const extension: CommentMarkdownExtension = {
+ remarkPlugins: [[remarkNativeChatVisuals, 'n']],
+ extraFlowSyntaxMarkers: [NATIVE_CHAT_VISUAL_DIRECTIVE_MARKER],
+ sanitizeAttributes: {},
+ components: {}
+ }
+ const view = render(
+
+ )
+ const fence = view.container.querySelector('pre')
+ const word = [...view.container.querySelectorAll('[data-word]')].find(
+ (node) => node.textContent === 'selected'
+ )!
+ const range = document.createRange()
+ range.selectNodeContents(word)
+ document.getSelection()?.addRange(range)
+ document.dispatchEvent(new Event('selectionchange'))
+ act(() => vi.advanceTimersByTime(150))
+ const content = `${prefix}\n\nselected words\n\n${directive()}\n\nnew words`
+ view.rerender(
+
+ )
+ expect(view.container.querySelector('pre')).toBe(fence)
+ expect(word.isConnected).toBe(true)
+ act(() => vi.advanceTimersByTime(150))
+ expect(word.isConnected).toBe(true)
+ expect(
+ [...view.container.querySelectorAll('[data-word]')].some((node) => node.textContent === 'new')
+ ).toBe(true)
+ view.rerender(
+
+ )
+ expect(document.getSelection()?.toString()).toBe('selected')
+ document.getSelection()?.removeAllRanges()
+ document.dispatchEvent(new Event('selectionchange'))
+ act(() => vi.advanceTimersByTime(1))
+ expect(word.isConnected).toBe(false)
+})
+
+function installWorkMeters(): void {
+ const clone = globalThis.structuredClone
+ vi.spyOn(globalThis, 'structuredClone').mockImplementation((value: T): T => {
+ if (meter.work && isRoot(value)) {
+ meter.work.cloneNodes += nodeCount(value)
+ meter.work.cloneBytes += JSON.stringify(value).length
+ }
+ return clone(value)
+ })
+ const includes = String.prototype.includes
+ vi.spyOn(String.prototype, 'includes').mockImplementation(function (
+ this: string,
+ marker,
+ position
+ ) {
+ if (meter.work && (marker === '\uFEFF' || marker === NATIVE_CHAT_VISUAL_DIRECTIVE_MARKER)) {
+ meter.work.scans.push({ source: String(this), marker })
+ }
+ return includes.call(this, marker, position)
+ })
+}
+
+it.each([20, 200])(
+ 'bounds actual parsing, clone, position and scan work at production updates N=%i',
+ (n) => {
+ installWorkMeters()
+ const fence = `\`\`\`text\n${'value\n'.repeat(n)}\`\`\`\n\n`
+ const tails = [
+ 'Next',
+ ...Array.from({ length: n }, (_, i) => `Next${'x'.repeat(i + 1)}`),
+ `Next${'x'.repeat(n)}\n\n${directive()}\n\nend`
+ ]
+ const runs: ReturnType[][] = []
+ for (const legacy of [true, false]) {
+ meter.legacy = legacy
+ meter.work = null
+ const view = render(native('', true, true, 'document'))
+ const updates: ReturnType[] = []
+ for (const tail of tails) {
+ meter.work = createParseWork()
+ view.rerender(native(fence + tail, true, true, 'document'))
+ updates.push(meter.work)
+ }
+ view.unmount()
+ runs.push(updates)
+ }
+ const optimized = runs[1]
+ expect(optimized[0].discovery).toEqual([fence + tails[0]])
+ for (let i = 1; i <= n; i++) {
+ const work = optimized[i]
+ expect(work.discovery).toEqual([tails[i]])
+ expect(work.render).toEqual([])
+ expect(work.shifts).toBe(4)
+ expect(work.cloneNodes).toBe(3)
+ expect(work.cloneBytes).toBeLessThan(tails[i].length + 400)
+ expect(work.blocks).toBe(2)
+ expect(work.scans).toEqual([
+ { source: fence + tails[i], marker: '\uFEFF' },
+ { source: tails[i], marker: NATIVE_CHAT_VISUAL_DIRECTIVE_MARKER }
+ ])
+ }
+ const rejected = optimized.at(-1)!
+ expect(rejected.discovery).toEqual([fence + tails.at(-1)])
+ expect(rejected.render.some((source) => source.includes('value\n'))).toBe(false)
+ expect(rejected.render.reduce((n, s) => n + s.length, 0)).toBeLessThanOrEqual(
+ runs[0].at(-1)!.render.reduce((n, s) => n + s.length, 0)
+ )
+ expect(rejected.shifts).toBe(0)
+ expect(rejected.cloneNodes).toBe(0)
+ expect(rejected.blocks).toBe(4)
+ }
+)
+
+it.each([20, 200])('keeps visual-adjacent fallback parse work within main at N=%i', (n) => {
+ const runs: ReturnType[][] = []
+ const outputs: string[][] = []
+ for (const legacy of [true, false]) {
+ meter.legacy = legacy
+ meter.work = null
+ const view = render(native(''))
+ const updates: ReturnType[] = []
+ const html: string[] = []
+ for (let i = 0; i <= n; i++) {
+ meter.work = createParseWork()
+ view.rerender(native(`${directive()}\n\n${'word '.repeat(i)}end`))
+ updates.push(meter.work)
+ html.push(view.container.innerHTML)
+ }
+ view.unmount()
+ runs.push(updates)
+ outputs.push(html)
+ }
+ expect(outputs[1]).toEqual(outputs[0])
+ for (let i = 0; i <= n; i++) {
+ expect(runs[1][i].discovery).toEqual(runs[0][i].discovery)
+ expect(runs[1][i].render).toEqual(runs[0][i].render)
+ expect(runs[1][i].shifts).toBe(0)
+ }
+})
+
+it('leaves old visual/fence elements mounted when reuse resumes beyond an earlier marker', () => {
+ const full = `${directive()}\n\n${prefix}`
+ const view = render(native(full))
+ const visual = view.container.querySelector('[data-visual-file]')
+ const fence = view.container.querySelector('pre')
+ view.rerender(native(`${full} words`))
+ meter.work = createParseWork()
+ view.rerender(native(`${full} words again`))
+ expect(meter.work.discovery).toEqual(['Next words again'])
+ view.rerender(native(`${full} words again\n\n${directive(1)}\n\nend`))
+ expect(view.container.querySelector('[data-visual-file]')).toBe(visual)
+ expect(view.container.querySelector('pre')).toBe(fence)
+})
+
+it('invalidates raw grammar metadata when an extension nonce changes on the same source', () => {
+ const makeExtension = (nonce: string): CommentMarkdownExtension => ({
+ remarkPlugins: [[remarkNativeChatVisuals, nonce]],
+ extraFlowSyntaxMarkers: [NATIVE_CHAT_VISUAL_DIRECTIVE_MARKER],
+ sanitizeAttributes: {},
+ components: {}
+ })
+ const snapshots: string[][] = []
+ for (const legacy of [true, false]) {
+ meter.legacy = legacy
+ const first = makeExtension('first'),
+ second = makeExtension('second')
+ const view = render(
+
+ )
+ view.rerender(
+
+ )
+ const html = [view.container.innerHTML]
+ view.rerender(
+
+ )
+ html.push(view.container.innerHTML)
+ snapshots.push(html)
+ view.unmount()
+ }
+ expect(snapshots[1]).toEqual(snapshots[0])
+})
+
+it.each([20, 200])(
+ 'excludes the settled prefix from syntax/clone work with a bounded live tail N=%i',
+ (n) => {
+ installWorkMeters()
+ const fence = `\`\`\`text\n${'value\n'.repeat(n)}\`\`\`\n\n`
+ let source = `${fence}word`
+ const view = render(native(source, true, true, 'document'))
+ for (let i = 1; i <= n; i++) {
+ source += '\n\nword'
+ meter.work = createParseWork()
+ view.rerender(native(source, true, true, 'document'))
+ expect(meter.work.discovery).toHaveLength(1)
+ expect(meter.work.discovery[0].length).toBeLessThanOrEqual(16)
+ expect(meter.work.render).toEqual([])
+ expect(meter.work.shifts).toBeLessThanOrEqual(8)
+ expect(meter.work.cloneNodes).toBeLessThanOrEqual(6)
+ expect(meter.work.cloneBytes).toBeLessThanOrEqual(800)
+ expect(meter.work.scans).toEqual([
+ { source, marker: '\uFEFF' },
+ { source: meter.work.discovery[0], marker: NATIVE_CHAT_VISUAL_DIRECTIVE_MARKER }
+ ])
+ expect(meter.work.blocks).toBe(i + 2)
+ }
+ }
+)
diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.tsx
index 0fe2ec02785..27d00c86bf7 100644
--- a/src/renderer/src/components/sidebar/CommentMarkdown.tsx
+++ b/src/renderer/src/components/sidebar/CommentMarkdown.tsx
@@ -3,11 +3,14 @@ import {
CommentMarkdownMermaidContext,
CommentMarkdownMermaidSourceContext
} from './comment-markdown-mermaid-policy'
-import Markdown, { defaultUrlTransform, type Components } from 'react-markdown'
+import {
+ defaultUrlTransform,
+ type Components,
+ type Options as MarkdownOptions
+} from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { remarkChatMath } from './chat-markdown-math'
-import { useChatMathRendering } from './chat-markdown-math-rendering'
import { copyChatMathSelection } from './chat-markdown-math-selection'
import remarkCjkFriendly from 'remark-cjk-friendly/parseOnly'
import rehypeRaw from 'rehype-raw'
@@ -25,14 +28,13 @@ import {
type FileLinkExists
} from './comment-markdown-native-chat-file-links'
import { CommentMarkdownBlockLineContext } from './comment-markdown-code-renderers'
-import { rehypeWordFade } from './comment-markdown-word-fade'
import { remarkUnknownHtmlAsText } from './comment-markdown-unknown-html-as-text'
import { CommentMarkdownWords } from './CommentMarkdownWords'
import {
- splitMarkdownTopLevelBlocks,
- type MarkdownBlock,
- type MarkdownBlockSplit
-} from './markdown-top-level-blocks'
+ MemoizedMarkdown,
+ useMarkdownBlocks,
+ markdownBlockContent
+} from './comment-markdown-block-pipeline'
import {
GITHUB_CALLOUT_SANITIZE_ATTRIBUTE,
remarkGitHubCallouts
@@ -41,9 +43,9 @@ import { remarkAutolinkLiteralProseBoundary } from '@/lib/remark-autolink-litera
export type { CommentMarkdownLinkClickHandler } from './comment-markdown-element-renderers'
-type MarkdownPlugins = NonNullable['rehypePlugins']>
-type RemarkPlugins = NonNullable['remarkPlugins']>
-type UrlTransform = NonNullable['urlTransform']>
+type MarkdownPlugins = NonNullable
+type RemarkPlugins = NonNullable
+type UrlTransform = NonNullable
type GitHubRepoReference = {
owner: string
@@ -232,6 +234,8 @@ const rehypePlugins: MarkdownPlugins = [rehypeRaw, [rehypeSanitize, commentMarkd
/** Keep extensions stable per surface; new objects rebuild components and remount their contents. */
export type CommentMarkdownExtension = {
remarkPlugins: RemarkPlugins
+ /** Extra flow syntax requires one of these markers; omitted means unknown grammar. */
+ extraFlowSyntaxMarkers?: readonly string[]
sanitizeAttributes: Record
components: Components
}
@@ -270,49 +274,6 @@ type CommentMarkdownProps = React.ComponentPropsWithoutRef<'div'> & {
unknownHtmlAsText?: boolean
}
-/** One render of the pipeline; memoized so an unchanged block is not parsed again. */
-const MemoizedMarkdown = React.memo(function DeferredMarkdown({
- math,
- fadeWords,
- ...props
-}: React.ComponentProps & { math: boolean; fadeWords: boolean }) {
- const renderMath = useChatMathRendering(math)
- const plugins = React.useMemo(
- () => [
- ...(props.rehypePlugins ?? []),
- ...(math ? [renderMath] : []),
- ...(fadeWords ? [rehypeWordFade] : [])
- ],
- [props.rehypePlugins, math, renderMath, fadeWords]
- )
- return
-})
-
-/** The content as the blocks to render. Cut into blocks from the first time it is growing, and
- * from then on, so the stream ending does not redraw what is already there. */
-function useMarkdownBlocks(
- content: string,
- growing: boolean,
- math: boolean
-): readonly MarkdownBlock[] {
- const [previous, setPrevious] = React.useState(null)
- if (!growing && previous === null) {
- return [{ start: 0, line: 1, text: content }]
- }
- if (previous?.source === content) {
- return previous.blocks
- }
- const next = splitMarkdownTopLevelBlocks(content, previous, math)
- setPrevious(next)
- return next.blocks
-}
-
-function markdownBlockContent(block: MarkdownBlock): string {
- return block.literalMath && block.end !== undefined
- ? block.text.slice(0, block.end - block.start)
- : block.text
-}
-
// Why forwardRef + rest props: Radix's HoverCardTrigger asChild merges a ref
// and event handlers (onPointerEnter, onPointerLeave, data-state, etc.) onto
// the child. Without forwarding both, the hover card cannot open or position.
@@ -371,7 +332,7 @@ const CommentMarkdown = React.memo(
const withExtension = extension ? [...withMath, ...extension.remarkPlugins] : withMath
return githubRepo ? [...withExtension, remarkGitHubReferences(githubRepo)] : withExtension
}, [extension, fileLinkExists, githubRepo, math, unknownHtmlAsText])
- const blocks = useMarkdownBlocks(content, growing, math)
+ const blocks = useMarkdownBlocks(content, growing, math, activeRemarkPlugins, extension)
const [hadWordFade, setHadWordFade] = React.useState(fadeWords)
if (fadeWords && !hadWordFade) {
setHadWordFade(true)
@@ -400,6 +361,7 @@ const CommentMarkdown = React.memo(
{blocks.map((block, index) => (
['remarkPlugins']>
+
+/** One render of the pipeline; memoized so an unchanged block is not parsed again. */
+export const MemoizedMarkdown = React.memo(function DeferredMarkdown({
+ math,
+ fadeWords,
+ block,
+ ...props
+}: React.ComponentProps & {
+ math: boolean
+ fadeWords: boolean
+ block?: MarkdownBlock
+}) {
+ const renderMath = useChatMathRendering(math)
+ const plugins = React.useMemo(
+ () => [
+ ...(props.rehypePlugins ?? []),
+ ...(math ? [renderMath] : []),
+ ...(fadeWords ? [rehypeWordFade] : [])
+ ],
+ [props.rehypePlugins, math, renderMath, fadeWords]
+ )
+ const parsedPlugins = React.useMemo(() => {
+ if (!block?.tree) {
+ return props.remarkPlugins ?? []
+ }
+ const tree = block.tree
+ const source = markdownBlockContent(block)
+ // Transforms mutate mdast; each render owns a fresh copy of the pristine parse.
+ function reuseParsedBlock(this: Processor): void {
+ const parse = this.parser
+ if (!parse) {
+ return
+ }
+ this.parser = (value, file) => (value === source ? structuredClone(tree) : parse(value, file))
+ }
+ return [...(props.remarkPlugins ?? []), reuseParsedBlock]
+ }, [block, props.remarkPlugins])
+ return
+})
+
+/** The content as the blocks to render. Cut into blocks from the first time it is growing, and
+ * from then on, so the stream ending does not redraw what is already there. */
+export function useMarkdownBlocks(
+ content: string,
+ growing: boolean,
+ math: boolean,
+ plugins: RemarkPlugins,
+ extension?: CommentMarkdownExtension
+): readonly MarkdownBlock[] {
+ const parser = React.useMemo(() => unified().use(remarkParse).use(plugins), [plugins])
+ const [state, setState] = React.useState<{
+ parser: typeof parser
+ split: MarkdownBlockSplit
+ } | null>(null)
+ if (state && state.parser !== parser) {
+ const split = {
+ ...state.split,
+ blocks: state.split.blocks.map(
+ ({ tree: _tree, closedFence: _closedFence, ...block }) => block
+ )
+ }
+ setState({ parser, split })
+ return split.blocks
+ }
+ const previous = state?.split ?? null
+ if (!growing && state === null) {
+ return [{ start: 0, line: 1, text: content }]
+ }
+ if (previous?.source === content) {
+ return previous.blocks
+ }
+ const markers = extension?.extraFlowSyntaxMarkers
+ const knownGrammar = !extension?.remarkPlugins.length || markers !== undefined
+ const next = splitMarkdownTopLevelBlocks(
+ content,
+ previous,
+ math,
+ knownGrammar ? (source) => parser.parse(source) : undefined,
+ (tail) => !content.includes('\uFEFF') && !markers?.some((marker) => tail.includes(marker))
+ )
+ setState({ parser, split: next })
+ return next.blocks
+}
+
+export function markdownBlockContent(block: MarkdownBlock): string {
+ return block.literalMath && block.end !== undefined
+ ? block.text.slice(0, block.end - block.start)
+ : block.text
+}
diff --git a/src/renderer/src/components/sidebar/markdown-parse-meter.test-fixture.ts b/src/renderer/src/components/sidebar/markdown-parse-meter.test-fixture.ts
new file mode 100644
index 00000000000..8c4d72be00e
--- /dev/null
+++ b/src/renderer/src/components/sidebar/markdown-parse-meter.test-fixture.ts
@@ -0,0 +1,51 @@
+import type { Nodes, Root } from 'mdast'
+
+type ParseWork = {
+ discovery: string[]
+ render: string[]
+ shifts: number
+ cloneNodes: number
+ cloneBytes: number
+ scans: { source: string; marker: string }[]
+ blocks: number
+}
+
+export function createParseWork(): ParseWork {
+ return {
+ discovery: [],
+ render: [],
+ shifts: 0,
+ cloneNodes: 0,
+ cloneBytes: 0,
+ scans: [],
+ blocks: 0
+ }
+}
+
+export function nodeCount(node: Nodes): number {
+ return 1 + ('children' in node ? node.children.reduce((n, child) => n + nodeCount(child), 0) : 0)
+}
+
+// Meter actual coordinate writes, leaving the parser's values and clone semantics unchanged.
+export function meterPositions(tree: Root, onWrite: () => void): void {
+ function visit(node: Nodes): void {
+ if (node.position) {
+ for (const point of [node.position.start, node.position.end]) {
+ let offset = point.offset
+ Object.defineProperty(point, 'offset', {
+ enumerable: true,
+ configurable: true,
+ get: () => offset,
+ set: (value: number) => {
+ onWrite()
+ offset = value
+ }
+ })
+ }
+ }
+ if ('children' in node) {
+ node.children.forEach(visit)
+ }
+ }
+ visit(tree)
+}
diff --git a/src/renderer/src/components/sidebar/markdown-top-level-blocks-legacy.test-fixture.ts b/src/renderer/src/components/sidebar/markdown-top-level-blocks-legacy.test-fixture.ts
new file mode 100644
index 00000000000..fb50574b17a
--- /dev/null
+++ b/src/renderer/src/components/sidebar/markdown-top-level-blocks-legacy.test-fixture.ts
@@ -0,0 +1,103 @@
+// Frozen splitter: differential tests compare every streaming prefix with the previous renderer.
+// Reparse the last two blocks on append; cross-block definitions or HTML fall back to the whole document.
+
+import type { Nodes } from 'mdast'
+import remarkGfm from 'remark-gfm'
+import remarkParse from 'remark-parse'
+import { remarkChatMath } from './chat-markdown-math'
+import { chatMathCodeFences } from './chat-markdown-math-nodes'
+import { unified } from 'unified'
+
+export type MarkdownBlock = {
+ /** Source offset, retained for settled blocks on append. */
+ start: number
+ /** One-based source line. */
+ line: number
+ text: string
+ literalMath?: boolean
+ /** Parser-owned end, excluding separators held for the next block. */
+ end?: number
+}
+
+export type MarkdownBlockSplit = {
+ source: string
+ blocks: readonly MarkdownBlock[]
+ /** Cross-block content requires whole-document rendering. */
+ whole: boolean
+}
+
+/** The last block, which is still growing, and the one it may yet join. */
+const UNSETTLED_BLOCKS = 2
+
+const blockParser = unified().use(remarkParse).use(remarkGfm)
+const mathBlockParser = blockParser().use(remarkChatMath)
+
+/** Tags that cannot hold anything, so cannot hold a later block. */
+const VOID_TAG_PATTERN = /^<(?:br|hr|wbr|img)\b[^>]*>$/i
+
+/** References and non-void HTML can affect other blocks and require whole-document parsing. */
+function reachesAcrossBlocks(node: Nodes): boolean {
+ if (node.type === 'definition' || node.type === 'footnoteDefinition') {
+ return true
+ }
+ if (node.type === 'html') {
+ return !VOID_TAG_PATTERN.test(node.value.trim())
+ }
+ return 'children' in node && node.children.some(reachesAcrossBlocks)
+}
+
+function hasLiteralMath(node: Nodes | undefined): boolean {
+ if (!node) {
+ return false
+ }
+ return (
+ node.data?.mathComplete === false ||
+ node.data?.mathLiteral === true ||
+ ('children' in node && node.children.some(hasLiteralMath))
+ )
+}
+
+function wholeDocument(source: string, literalMath = false): MarkdownBlockSplit {
+ return { source, blocks: [{ start: 0, line: 1, text: source, literalMath }], whole: true }
+}
+
+export function splitMarkdownTopLevelBlocks(
+ source: string,
+ previous: MarkdownBlockSplit | null,
+ math = false
+): MarkdownBlockSplit {
+ const grewFromPrevious = previous !== null && source.startsWith(previous.source)
+ if (grewFromPrevious && previous.whole && !math) {
+ return wholeDocument(source)
+ }
+ const settled = grewFromPrevious ? previous.blocks.slice(0, -UNSETTLED_BLOCKS) : []
+ const reparsed = grewFromPrevious ? previous.blocks.at(-UNSETTLED_BLOCKS) : undefined
+ const base = reparsed?.start ?? 0
+ const baseLine = reparsed?.line ?? 1
+ const tail = source.slice(base)
+ const tree = (math ? mathBlockParser : blockParser).parse(tail)
+ if (math) {
+ chatMathCodeFences(tree, tail)
+ }
+ if (reachesAcrossBlocks(tree)) {
+ return wholeDocument(source, math && hasLiteralMath(tree))
+ }
+ // Each block runs to the next one's start, so the blank lines between stay in the source.
+ const points = tree.children.map((child) =>
+ 'position' in child ? child.position?.start : undefined
+ )
+ const starts = points.map((point) => point?.offset ?? 0)
+ starts[0] = 0
+ const blocks = starts.map((start, index) => ({
+ start: base + start,
+ line: baseLine + (index === 0 ? 0 : (points[index]?.line ?? 1) - 1),
+ text: tail.slice(start, starts[index + 1]),
+ ...(math
+ ? {
+ literalMath: hasLiteralMath(tree.children[index]),
+ end: base + (tree.children[index]?.position?.end.offset ?? tail.length)
+ }
+ : {})
+ }))
+ return { source, blocks: [...settled, ...blocks], whole: false }
+}
diff --git a/src/renderer/src/components/sidebar/markdown-top-level-blocks.ts b/src/renderer/src/components/sidebar/markdown-top-level-blocks.ts
index 8a925d78f30..b007fe1be52 100644
--- a/src/renderer/src/components/sidebar/markdown-top-level-blocks.ts
+++ b/src/renderer/src/components/sidebar/markdown-top-level-blocks.ts
@@ -1,6 +1,6 @@
-// Reparse the last two blocks on append; cross-block definitions or HTML fall back to the whole document.
+// Reparse unsettled blocks; cross-block definitions or HTML require the whole document.
-import type { Nodes } from 'mdast'
+import type { Nodes, Root } from 'mdast'
import remarkGfm from 'remark-gfm'
import remarkParse from 'remark-parse'
import { remarkChatMath } from './chat-markdown-math'
@@ -16,6 +16,9 @@ export type MarkdownBlock = {
literalMath?: boolean
/** Parser-owned end, excluding separators held for the next block. */
end?: number
+ /** Pristine tree in block-local coordinates, before rendering transforms. */
+ tree?: Root
+ closedFence?: boolean
}
export type MarkdownBlockSplit = {
@@ -63,23 +66,43 @@ function wholeDocument(source: string, literalMath = false): MarkdownBlockSplit
export function splitMarkdownTopLevelBlocks(
source: string,
previous: MarkdownBlockSplit | null,
- math = false
+ math = false,
+ parseTree?: (source: string) => Root,
+ canReuse: (tail: string) => boolean = () => !source.includes('\uFEFF')
): MarkdownBlockSplit {
+ let parse = parseTree
const grewFromPrevious = previous !== null && source.startsWith(previous.source)
if (grewFromPrevious && previous.whole && !math) {
return wholeDocument(source)
}
- const settled = grewFromPrevious ? previous.blocks.slice(0, -UNSETTLED_BLOCKS) : []
- const reparsed = grewFromPrevious ? previous.blocks.at(-UNSETTLED_BLOCKS) : undefined
+ // A sliced, indented opener must retain the original discovery context.
+ const last = grewFromPrevious ? previous.blocks.at(-1) : undefined
+ let unsettled =
+ parse &&
+ grewFromPrevious &&
+ previous.blocks.at(-UNSETTLED_BLOCKS)?.closedFence &&
+ last &&
+ source[last.start - 1] === '\n'
+ ? 1
+ : UNSETTLED_BLOCKS
+ // Declining must restore the original discovery window before choosing any cuts.
+ const candidateBase = grewFromPrevious ? (previous.blocks.at(-unsettled)?.start ?? 0) : 0
+ if (parse && !canReuse(source.slice(candidateBase))) {
+ parse = undefined
+ unsettled = UNSETTLED_BLOCKS
+ }
+ const settled = grewFromPrevious ? previous.blocks.slice(0, -unsettled) : []
+ const reparsed = grewFromPrevious ? previous.blocks.at(-unsettled) : undefined
const base = reparsed?.start ?? 0
const baseLine = reparsed?.line ?? 1
const tail = source.slice(base)
- const tree = (math ? mathBlockParser : blockParser).parse(tail)
+ const tree = parse ? parse(tail) : (math ? mathBlockParser : blockParser).parse(tail)
if (math) {
chatMathCodeFences(tree, tail)
}
if (reachesAcrossBlocks(tree)) {
- return wholeDocument(source, math && hasLiteralMath(tree))
+ const whole = wholeDocument(source, math && hasLiteralMath(tree))
+ return parse && base === 0 ? { ...whole, blocks: [{ ...whole.blocks[0], tree }] } : whole
}
// Each block runs to the next one's start, so the blank lines between stay in the source.
const points = tree.children.map((child) =>
@@ -87,16 +110,68 @@ export function splitMarkdownTopLevelBlocks(
)
const starts = points.map((point) => point?.offset ?? 0)
starts[0] = 0
- const blocks = starts.map((start, index) => ({
- start: base + start,
- line: baseLine + (index === 0 ? 0 : (points[index]?.line ?? 1) - 1),
- text: tail.slice(start, starts[index + 1]),
- ...(math
- ? {
- literalMath: hasLiteralMath(tree.children[index]),
- end: base + (tree.children[index]?.position?.end.offset ?? tail.length)
- }
- : {})
- }))
+ const blocks = starts.map((start, index): MarkdownBlock => {
+ const child = tree.children[index]
+ const text = tail.slice(start, starts[index + 1])
+ const line = index === 0 ? 1 : (points[index]?.line ?? 1)
+ const block: MarkdownBlock = {
+ start: base + start,
+ line: baseLine + line - 1,
+ text,
+ ...(math
+ ? {
+ literalMath: hasLiteralMath(child),
+ end: base + (child?.position?.end.offset ?? tail.length)
+ }
+ : {})
+ }
+ const prior = grewFromPrevious ? previous.blocks[settled.length + index] : undefined
+ if (
+ prior?.tree &&
+ prior.start === block.start &&
+ prior.text === text &&
+ prior.literalMath === block.literalMath &&
+ prior.end === block.end
+ ) {
+ return prior
+ }
+ if (parse && child) {
+ block.closedFence = isClosedFence(child, text, start)
+ // Slicing an indented opener changes its standalone parse, so preserve that parse.
+ if (index === 0 || points[index]?.column === 1) {
+ block.tree = { type: 'root', children: [child] }
+ shiftMarkdownPositions(block.tree, start, line - 1)
+ }
+ }
+ return block
+ })
return { source, blocks: [...settled, ...blocks], whole: false }
}
+
+function shiftMarkdownPositions(node: Nodes, offset: number, lines: number): void {
+ if (node.position) {
+ for (const point of [node.position.start, node.position.end]) {
+ if (point.offset !== undefined) {
+ point.offset -= offset
+ }
+ point.line -= lines
+ }
+ }
+ if ('children' in node) {
+ node.children.forEach((child) => shiftMarkdownPositions(child, offset, lines))
+ }
+}
+
+function isClosedFence(node: Nodes, source: string, offset: number): boolean {
+ if (node.type !== 'code') {
+ return false
+ }
+ const opening = /^ {0,3}(`{3,}|~{3,})[^\r\n]*\r?\n/.exec(source)?.[1]
+ if (!opening || !node.position) {
+ return false
+ }
+ const end = (node.position.end.offset ?? offset + source.length) - offset
+ const authored = source.slice(0, end)
+ const lastLine = authored.slice(authored.lastIndexOf('\n') + 1).replace(/\r$/, '')
+ return new RegExp(`^ {0,3}${opening[0]}{${opening.length},}[ \t]*$`).test(lastLine)
+}