Merge pull request #47664 from NousResearch/bb/desktop-markdown-spread-overflow
fix(desktop): stop a single message from crashing or freezing the chat
This commit is contained in:
commit
f10f7114f9
@ -322,13 +322,29 @@ function shortLabel(type: HermesRefType, id: string): string {
|
|||||||
return tail || id
|
return tail || id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function safeEmbeddedImages(text: string) {
|
||||||
|
try {
|
||||||
|
return extractEmbeddedImages(text)
|
||||||
|
} catch {
|
||||||
|
return { cleanedText: text, images: [] as string[] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeDirectiveSegments(text: string): Unstable_DirectiveSegment[] {
|
||||||
|
try {
|
||||||
|
return [...hermesDirectiveFormatter.parse(text)]
|
||||||
|
} catch {
|
||||||
|
return [{ kind: 'text', text }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders text containing Hermes directives (`@file:...`, `@image:...`) as
|
* Renders text containing Hermes directives (`@file:...`, `@image:...`) as
|
||||||
* inline chips. Embedded MEDIA images render below as a thumbnail row.
|
* inline chips. Embedded MEDIA images render below as a thumbnail row.
|
||||||
*/
|
*/
|
||||||
export function DirectiveContent({ text }: { text: string }) {
|
export function DirectiveContent({ text }: { text: string }) {
|
||||||
const { cleanedText, images } = useMemo(() => extractEmbeddedImages(text ?? ''), [text])
|
const { cleanedText, images } = useMemo(() => safeEmbeddedImages(text ?? ''), [text])
|
||||||
const segments = useMemo(() => hermesDirectiveFormatter.parse(cleanedText), [cleanedText])
|
const segments = useMemo(() => safeDirectiveSegments(cleanedText), [cleanedText])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="whitespace-pre-line" data-slot="aui_directive-text">
|
<span className="whitespace-pre-line" data-slot="aui_directive-text">
|
||||||
|
|||||||
@ -201,4 +201,13 @@ describe('preprocessMarkdown', () => {
|
|||||||
|
|
||||||
expect(output).toContain('<https://example.com/a_b/c~d/page>')
|
expect(output).toContain('<https://example.com/a_b/c~d/page>')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('handles a fenced block larger than V8 spread-argument limit', () => {
|
||||||
|
// A single huge code block (e.g. a logged minified bundle) used to throw
|
||||||
|
// `RangeError: Maximum call stack size exceeded` via `out.push(...lines)`.
|
||||||
|
const body = Array.from({ length: 200_000 }, (_, i) => `line ${i}`).join('\n')
|
||||||
|
const input = `\`\`\`js\n${body}\n\`\`\``
|
||||||
|
|
||||||
|
expect(() => preprocessMarkdown(input)).not.toThrow()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -19,8 +19,9 @@ import {
|
|||||||
useState
|
useState
|
||||||
} from 'react'
|
} from 'react'
|
||||||
|
|
||||||
|
import { ExpandableBlock } from '@/components/chat/expandable-block'
|
||||||
import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
||||||
import { SyntaxHighlighter } from '@/components/chat/shiki-highlighter'
|
import { chunkByLines, SyntaxHighlighter } from '@/components/chat/shiki-highlighter'
|
||||||
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
||||||
import { normalizeExternalUrl, openExternalLink, PrettyLink } from '@/lib/external-link'
|
import { normalizeExternalUrl, openExternalLink, PrettyLink } from '@/lib/external-link'
|
||||||
import { createMemoizedMathPlugin } from '@/lib/katex-memo'
|
import { createMemoizedMathPlugin } from '@/lib/katex-memo'
|
||||||
@ -57,7 +58,11 @@ const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true })
|
|||||||
// flush) with a tail-bounded repair — see lib/remend-tail.ts. Must stay
|
// flush) with a tail-bounded repair — see lib/remend-tail.ts. Must stay
|
||||||
// module-scope so the prop identity is stable across renders.
|
// module-scope so the prop identity is stable across renders.
|
||||||
function preprocessWithTailRepair(text: string): string {
|
function preprocessWithTailRepair(text: string): string {
|
||||||
return tailBoundedRemend(preprocessMarkdown(text))
|
try {
|
||||||
|
return tailBoundedRemend(preprocessMarkdown(text))
|
||||||
|
} catch {
|
||||||
|
return text
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Memoized block splitter. Streamdown calls `parseMarkdownIntoBlocks` (a full
|
// Memoized block splitter. Streamdown calls `parseMarkdownIntoBlocks` (a full
|
||||||
@ -453,8 +458,35 @@ const MARKDOWN_CONTAINER_CLASS_NAME = cn(
|
|||||||
'[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-(--paragraph-gap)'
|
'[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-(--paragraph-gap)'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const MAX_MARKDOWN_CHARS = 200_000
|
||||||
|
|
||||||
|
function HugeTextFallback({ containerClassName, text }: { containerClassName?: string; text: string }) {
|
||||||
|
const chunks = useMemo(() => chunkByLines(text, 200), [text])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'aui-md w-full max-w-none overflow-hidden rounded-[0.625rem] border border-border font-mono text-[0.7rem] leading-relaxed text-foreground/90',
|
||||||
|
containerClassName
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ExpandableBlock className="p-2">
|
||||||
|
{chunks.map((chunk, index) => (
|
||||||
|
<div
|
||||||
|
className="[content-visibility:auto]"
|
||||||
|
key={index}
|
||||||
|
style={{ containIntrinsicSize: `auto ${chunk.lines * 16}px` }}
|
||||||
|
>
|
||||||
|
{chunk.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</ExpandableBlock>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTextSurfaceProps) {
|
function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTextSurfaceProps) {
|
||||||
const { status } = useMessagePartText()
|
const { status, text } = useMessagePartText()
|
||||||
const isStreaming = status.type === 'running'
|
const isStreaming = status.type === 'running'
|
||||||
|
|
||||||
// Keep code parsing enabled while streaming so incomplete fenced blocks still
|
// Keep code parsing enabled while streaming so incomplete fenced blocks still
|
||||||
@ -551,6 +583,10 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
|
|||||||
[isStreaming]
|
[isStreaming]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (text.length > MAX_MARKDOWN_CHARS) {
|
||||||
|
return <HugeTextFallback containerClassName={containerClassName} text={text} />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StreamdownTextPrimitive
|
<StreamdownTextPrimitive
|
||||||
components={components}
|
components={components}
|
||||||
|
|||||||
@ -66,7 +66,7 @@ function CodeCardBody({ className, ...props }: React.ComponentProps<'div'>) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'p-1.5 font-mono text-[0.7rem] leading-relaxed text-foreground/90 [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:px-2 [&_pre]:py-1.5 [&_pre]:font-mono [&_pre]:leading-relaxed',
|
'font-mono text-[0.7rem] leading-relaxed text-foreground/90 [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:px-2 [&_pre]:py-1.5 [&_pre]:font-mono [&_pre]:leading-relaxed',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
data-slot="code-card-body"
|
data-slot="code-card-body"
|
||||||
|
|||||||
52
apps/desktop/src/components/chat/expandable-block.tsx
Normal file
52
apps/desktop/src/components/chat/expandable-block.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { type ReactNode, useLayoutEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { ChevronDown } from '@/lib/icons'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface ExpandableBlockProps {
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpandableBlock({ children, className }: ExpandableBlockProps) {
|
||||||
|
const innerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
const [overflowing, setOverflowing] = useState(false)
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = innerRef.current
|
||||||
|
|
||||||
|
if (!el) {return}
|
||||||
|
|
||||||
|
const measure = () => setOverflowing(el.scrollHeight > 121)
|
||||||
|
measure()
|
||||||
|
const observer = new ResizeObserver(measure)
|
||||||
|
observer.observe(el)
|
||||||
|
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
className={cn('overflow-y-auto', expanded ? 'max-h-[40dvh]' : 'max-h-[7.5rem]', className)}
|
||||||
|
ref={innerRef}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{overflowing && (
|
||||||
|
<button
|
||||||
|
aria-expanded={expanded}
|
||||||
|
aria-label={expanded ? 'Collapse' : 'Expand'}
|
||||||
|
className="absolute inset-x-0 bottom-0 flex h-7 cursor-pointer items-end justify-center bg-linear-to-t from-(--ui-chat-surface-background) to-transparent pb-1 text-muted-foreground/70 transition-colors hover:text-foreground"
|
||||||
|
onClick={() => setExpanded(v => !v)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ChevronDown className={cn('size-3.5 transition-transform', expanded && 'rotate-180')} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
apps/desktop/src/components/chat/shiki-highlighter.test.ts
Normal file
37
apps/desktop/src/components/chat/shiki-highlighter.test.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { chunkByLines, exceedsHighlightBudget } from '@/components/chat/shiki-highlighter'
|
||||||
|
|
||||||
|
describe('exceedsHighlightBudget', () => {
|
||||||
|
it('highlights normal-sized blocks', () => {
|
||||||
|
expect(exceedsHighlightBudget('const x = 1\n'.repeat(100))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips highlighting past the line budget', () => {
|
||||||
|
expect(exceedsHighlightBudget('x\n'.repeat(5_000))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips highlighting past the char budget on few lines', () => {
|
||||||
|
expect(exceedsHighlightBudget('a'.repeat(200_000))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('short-circuits on char budget before line loop', () => {
|
||||||
|
expect(exceedsHighlightBudget('y\n'.repeat(250_000))).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('chunkByLines', () => {
|
||||||
|
it('keeps a small block as a single chunk', () => {
|
||||||
|
const code = 'a\nb\nc'
|
||||||
|
expect(chunkByLines(code, 200)).toEqual([{ text: code, lines: 3 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('splits a large block and reconstructs it losslessly', () => {
|
||||||
|
const code = Array.from({ length: 1000 }, (_, i) => `line ${i}`).join('\n')
|
||||||
|
const chunks = chunkByLines(code, 200)
|
||||||
|
|
||||||
|
expect(chunks).toHaveLength(5)
|
||||||
|
expect(chunks.map(chunk => chunk.text).join('\n')).toBe(code)
|
||||||
|
expect(chunks.reduce((sum, chunk) => sum + chunk.lines, 0)).toBe(1000)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import type { SyntaxHighlighterProps } from '@assistant-ui/react-streamdown'
|
import type { SyntaxHighlighterProps } from '@assistant-ui/react-streamdown'
|
||||||
import type { FC } from 'react'
|
import { type FC, useMemo } from 'react'
|
||||||
import ShikiHighlighter from 'react-shiki'
|
import ShikiHighlighter from 'react-shiki'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -12,6 +12,7 @@ import {
|
|||||||
CodeCardSubtitle,
|
CodeCardSubtitle,
|
||||||
CodeCardTitle
|
CodeCardTitle
|
||||||
} from '@/components/chat/code-card'
|
} from '@/components/chat/code-card'
|
||||||
|
import { ExpandableBlock } from '@/components/chat/expandable-block'
|
||||||
import { CopyButton } from '@/components/ui/copy-button'
|
import { CopyButton } from '@/components/ui/copy-button'
|
||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
import { codiconForLanguage, isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code'
|
import { codiconForLanguage, isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code'
|
||||||
@ -43,6 +44,74 @@ const SHIKI_COLOR_REPLACEMENTS: Record<string, Record<string, string>> = {
|
|||||||
'github-light-default': { '#6e7781': '#57606a' }
|
'github-light-default': { '#6e7781': '#57606a' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_HIGHLIGHT_CHARS = 150_000
|
||||||
|
const MAX_HIGHLIGHT_LINES = 3_000
|
||||||
|
const CHUNK_LINES = 200
|
||||||
|
const EST_LINE_PX = 16
|
||||||
|
|
||||||
|
export function exceedsHighlightBudget(code: string): boolean {
|
||||||
|
if (code.length > MAX_HIGHLIGHT_CHARS) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
let lines = 1
|
||||||
|
let idx = code.indexOf('\n')
|
||||||
|
|
||||||
|
while (idx !== -1) {
|
||||||
|
if ((lines += 1) > MAX_HIGHLIGHT_LINES) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
idx = code.indexOf('\n', idx + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodeChunk {
|
||||||
|
text: string
|
||||||
|
lines: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chunkByLines(code: string, perChunk: number): CodeChunk[] {
|
||||||
|
const lines = code.split('\n')
|
||||||
|
|
||||||
|
if (lines.length <= perChunk) {
|
||||||
|
return [{ text: code, lines: lines.length }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: CodeChunk[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i += perChunk) {
|
||||||
|
const slice = lines.slice(i, i + perChunk)
|
||||||
|
chunks.push({ text: slice.join('\n'), lines: slice.length })
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlainCode: FC<{ code: string }> = ({ code }) => {
|
||||||
|
const chunks = useMemo(() => chunkByLines(code, CHUNK_LINES), [code])
|
||||||
|
|
||||||
|
if (chunks.length === 1) {
|
||||||
|
return <code className="block whitespace-pre">{code}</code>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{chunks.map((chunk, index) => (
|
||||||
|
<code
|
||||||
|
className="block whitespace-pre [content-visibility:auto]"
|
||||||
|
key={index}
|
||||||
|
style={{ containIntrinsicSize: `auto ${chunk.lines * EST_LINE_PX}px` }}
|
||||||
|
>
|
||||||
|
{chunk.text}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
|
export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
|
||||||
components: { Pre },
|
components: { Pre },
|
||||||
language,
|
language,
|
||||||
@ -64,6 +133,7 @@ export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
|
|||||||
|
|
||||||
const cleanLanguage = sanitizeLanguageTag(language || '')
|
const cleanLanguage = sanitizeLanguageTag(language || '')
|
||||||
const label = cleanLanguage && cleanLanguage !== 'unknown' ? cleanLanguage : ''
|
const label = cleanLanguage && cleanLanguage !== 'unknown' ? cleanLanguage : ''
|
||||||
|
const plain = defer || exceedsHighlightBudget(trimmed)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CodeCard data-streaming={defer ? 'true' : undefined}>
|
<CodeCard data-streaming={defer ? 'true' : undefined}>
|
||||||
@ -83,24 +153,26 @@ export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
|
|||||||
/>
|
/>
|
||||||
</CodeCardHeader>
|
</CodeCardHeader>
|
||||||
<CodeCardBody>
|
<CodeCardBody>
|
||||||
<Pre className="aui-shiki m-0 overflow-hidden bg-transparent p-0">
|
<ExpandableBlock>
|
||||||
{defer ? (
|
<Pre className="aui-shiki m-0 overflow-hidden bg-transparent p-0">
|
||||||
<code className="block whitespace-pre">{trimmed}</code>
|
{plain ? (
|
||||||
) : (
|
<PlainCode code={trimmed} />
|
||||||
<ShikiHighlighter
|
) : (
|
||||||
addDefaultStyles={false}
|
<ShikiHighlighter
|
||||||
as="div"
|
addDefaultStyles={false}
|
||||||
colorReplacements={SHIKI_COLOR_REPLACEMENTS}
|
as="div"
|
||||||
defaultColor="light-dark()"
|
colorReplacements={SHIKI_COLOR_REPLACEMENTS}
|
||||||
delay={120}
|
defaultColor="light-dark()"
|
||||||
language={language || 'text'}
|
delay={120}
|
||||||
showLanguage={false}
|
language={language || 'text'}
|
||||||
theme={SHIKI_THEME}
|
showLanguage={false}
|
||||||
>
|
theme={SHIKI_THEME}
|
||||||
{trimmed}
|
>
|
||||||
</ShikiHighlighter>
|
{trimmed}
|
||||||
)}
|
</ShikiHighlighter>
|
||||||
</Pre>
|
)}
|
||||||
|
</Pre>
|
||||||
|
</ExpandableBlock>
|
||||||
</CodeCardBody>
|
</CodeCardBody>
|
||||||
</CodeCard>
|
</CodeCard>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -151,12 +151,18 @@ function normalizeVisibleProse(text: string): string {
|
|||||||
.join('')
|
.join('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extend(out: string[], lines: string[]) {
|
||||||
|
for (const line of lines) {
|
||||||
|
out.push(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function pushProseFence(out: string[], indent: string, info: string, lines: string[]) {
|
function pushProseFence(out: string[], indent: string, info: string, lines: string[]) {
|
||||||
if (info) {
|
if (info) {
|
||||||
out.push(`${indent}${info}`.trimEnd())
|
out.push(`${indent}${info}`.trimEnd())
|
||||||
}
|
}
|
||||||
|
|
||||||
out.push(...lines)
|
extend(out, lines)
|
||||||
}
|
}
|
||||||
|
|
||||||
function findClosingFence(lines: string[], start: number, marker: string): number {
|
function findClosingFence(lines: string[], start: number, marker: string): number {
|
||||||
@ -241,7 +247,7 @@ function normalizeFenceBlocks(text: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (closeIndex !== -1 && isUrlOnlyBlock(bodyLines)) {
|
if (closeIndex !== -1 && isUrlOnlyBlock(bodyLines)) {
|
||||||
out.push(...bodyLines)
|
extend(out, bodyLines)
|
||||||
index = closeIndex + 1
|
index = closeIndex + 1
|
||||||
|
|
||||||
continue
|
continue
|
||||||
@ -264,10 +270,10 @@ function normalizeFenceBlocks(text: string): string {
|
|||||||
// any literal `$$` characters in the body don't collide with
|
// any literal `$$` characters in the body don't collide with
|
||||||
// an outer math wrapper. No close emitted yet — streaming.
|
// an outer math wrapper. No close emitted yet — streaming.
|
||||||
out.push(`${indent}${marker}math`)
|
out.push(`${indent}${marker}math`)
|
||||||
out.push(...bodyLines)
|
extend(out, bodyLines)
|
||||||
} else {
|
} else {
|
||||||
out.push(`${indent}${marker}${language}`)
|
out.push(`${indent}${marker}${language}`)
|
||||||
out.push(...bodyLines)
|
extend(out, bodyLines)
|
||||||
}
|
}
|
||||||
|
|
||||||
break
|
break
|
||||||
@ -288,7 +294,7 @@ function normalizeFenceBlocks(text: string): string {
|
|||||||
// colliding with our wrapper. Without this rewrite the block
|
// colliding with our wrapper. Without this rewrite the block
|
||||||
// would render as a syntax-highlighted "latex" code listing.
|
// would render as a syntax-highlighted "latex" code listing.
|
||||||
out.push(`${indent}${marker}math`)
|
out.push(`${indent}${marker}math`)
|
||||||
out.push(...bodyLines)
|
extend(out, bodyLines)
|
||||||
out.push(`${indent}${marker}`)
|
out.push(`${indent}${marker}`)
|
||||||
index = closeIndex + 1
|
index = closeIndex + 1
|
||||||
|
|
||||||
@ -296,7 +302,7 @@ function normalizeFenceBlocks(text: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
out.push(`${indent}${marker}${language}`)
|
out.push(`${indent}${marker}${language}`)
|
||||||
out.push(...bodyLines)
|
extend(out, bodyLines)
|
||||||
out.push(`${indent}${marker}`)
|
out.push(`${indent}${marker}`)
|
||||||
index = closeIndex + 1
|
index = closeIndex + 1
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user