fix: 修复嵌套 markdown fence 导致的渲染截断 (#222)

* fix: keep nested markdown fences rendered

* fix: prevent thinking placeholder leaks

* fix: normalize nested markdown example fences
This commit is contained in:
Zhicheng Han
2026-04-26 03:39:49 +02:00
committed by GitHub
parent d2ab2bca08
commit d0bd09ae57
5 changed files with 400 additions and 6 deletions
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
import { useMessage } from 'naive-ui'
import MarkdownIt from 'markdown-it'
import { handleCodeBlockCopyClick, renderHighlightedCodeBlock } from './highlight'
import { repairNestedMarkdownFences } from './markdownFenceRepair'
import { downloadFile } from '@/api/hermes/download'
const props = withDefaults(defineProps<{
@@ -26,7 +27,7 @@ const md: MarkdownIt = new MarkdownIt({
})
const renderedHtml = computed(() => {
let html = md.render(props.content)
let html = md.render(repairNestedMarkdownFences(props.content))
if (props.mentionNames && props.mentionNames.length > 0) {
const escaped = props.mentionNames.map(n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
const re = new RegExp(`(?<=[\\s>]|^)@(${escaped.join('|')})(?=\\s|$)`, 'gi')
@@ -0,0 +1,216 @@
const MARKDOWN_FENCE_LANGUAGES = new Set(['md', 'markdown', 'mdown', 'mkd'])
type FenceInfo = {
indent: string
marker: string
fence: string
length: number
info: string
}
function parseFence(line: string): FenceInfo | null {
const match = line.match(/^( {0,3})(`{3,}|~{3,})(.*)$/)
if (!match) return null
const [, indent, fence, rawInfo = ''] = match
const marker = fence[0]
const info = rawInfo.trim()
// CommonMark permits backticks in tilde-fence info strings, but not in
// backtick-fence info strings. Keeping this distinction prevents inline-ish
// malformed backtick text from being promoted into a fence opener.
if (marker === '`' && info.includes('`')) return null
return {
indent,
marker,
fence,
length: fence.length,
info,
}
}
function serializeFence(fence: FenceInfo, length = fence.length, info = fence.info): string {
return `${fence.indent}${fence.marker.repeat(length)}${info ? ` ${info}` : ''}`
}
function isMarkdownFence(fence: FenceInfo): boolean {
const language = fence.info.split(/\s+/)[0]?.toLowerCase()
return MARKDOWN_FENCE_LANGUAGES.has(language)
}
function isClosingFence(line: string, opener: FenceInfo): boolean {
const fence = parseFence(line)
return Boolean(
fence
&& fence.marker === opener.marker
&& fence.length >= opener.length
&& fence.info === '',
)
}
function findLastNonEmptyLine(lines: string[], start = lines.length - 1): number {
let index = start
while (index >= 0 && lines[index].trim() === '') {
index -= 1
}
return index
}
function findFinalClosingFence(lines: string[], opener: FenceInfo, start: number): number {
for (let i = findLastNonEmptyLine(lines); i > start; i -= 1) {
if (isClosingFence(lines[i], opener)) {
return i
}
}
return -1
}
type OpenFence = {
marker: string
length: number
}
function canBalanceNestedFences(lines: string[], marker: string): boolean {
const stack: OpenFence[] = []
let sawFence = false
for (const line of lines) {
const fence = parseFence(line)
if (!fence || fence.marker !== marker) continue
sawFence = true
const current = stack[stack.length - 1]
if (fence.info === '' && current && fence.length >= current.length) {
stack.pop()
continue
}
// Inside a Markdown example, an unlabeled fence can be either a closing
// fence or a literal nested unlabeled example opener. If there is no nested
// opener waiting to close, treat it as the latter while evaluating a later
// candidate closing fence for the outer example.
stack.push({ marker: fence.marker, length: fence.length })
}
return sawFence && stack.length === 0
}
function findBalancedClosingFence(lines: string[], opener: FenceInfo, start: number): number {
const candidates: number[] = []
for (let i = start; i < lines.length; i += 1) {
const fence = parseFence(lines[i])
if (
fence
&& fence.marker === opener.marker
&& fence.info === ''
&& fence.length >= opener.length
) {
candidates.push(i)
}
}
for (let i = candidates.length - 1; i >= 0; i -= 1) {
const candidate = candidates[i]
if (canBalanceNestedFences(lines.slice(start, candidate), opener.marker)) {
return candidate
}
}
return candidates[0] ?? -1
}
function maxFenceLength(lines: string[], marker: string): number {
let maxLength = 0
for (const line of lines) {
const fence = parseFence(line)
if (fence?.marker === marker) {
maxLength = Math.max(maxLength, fence.length)
}
}
return maxLength
}
function promoteMarkdownExampleFences(lines: string[]): string[] {
const output: string[] = []
for (let i = 0; i < lines.length; i += 1) {
const opener = parseFence(lines[i])
if (!opener || !isMarkdownFence(opener)) {
output.push(lines[i])
continue
}
const balancedClose = findBalancedClosingFence(lines, opener, i + 1)
if (balancedClose === -1) {
output.push(lines[i])
continue
}
const body = lines.slice(i + 1, balancedClose)
const innerMaxLength = maxFenceLength(body, opener.marker)
if (innerMaxLength >= opener.length) {
const promotedLength = innerMaxLength + 1
output.push(serializeFence(opener, promotedLength))
output.push(...body)
output.push(serializeFence(opener, promotedLength, ''))
} else {
output.push(lines[i])
output.push(...body)
output.push(lines[balancedClose])
}
i = balancedClose
}
return output
}
/**
* LLMs often wrap a complete PR draft or Markdown answer in an outer
* ```md fence. Showing that outer wrapper as a code block makes the UI look
* like Markdown rendering is broken: headings, lists, and inline code remain
* literal text. Strip only that outer draft wrapper before handing content to
* markdown-it.
*
* The unwrapped draft can still contain Markdown examples that themselves
* contain fenced examples. CommonMark closes fences at the first same-marker
* line with at least the opener length, so a malformed example like
* ```md ... ```md ... ``` ... ``` must be normalized by making the example's
* outer fence longer than the literal fences inside it.
*/
export function repairNestedMarkdownFences(content: string): string {
if (!content.includes('```') && !content.includes('~~~')) return content
const lines = content.split('\n')
const output: string[] = []
let changed = false
for (let i = 0; i < lines.length; i += 1) {
const opener = parseFence(lines[i])
if (!opener || !isMarkdownFence(opener)) {
output.push(lines[i])
continue
}
const finalClose = findFinalClosingFence(lines, opener, i + 1)
if (finalClose === -1) {
output.push(lines[i])
continue
}
const lastNonEmpty = findLastNonEmptyLine(lines)
if (finalClose !== lastNonEmpty) {
output.push(lines[i])
continue
}
output.push(...promoteMarkdownExampleFences(lines.slice(i + 1, finalClose)))
output.push(...lines.slice(finalClose + 1))
changed = true
break
}
return changed ? output.join('\n') : content
}
+15 -5
View File
@@ -14,7 +14,7 @@ const TAG_RE = /<(think|thinking|reasoning)>([\s\S]*?)<\/\1>/gi
const PLACEHOLDER_PREFIX = '\u0000THKCODE'
const PLACEHOLDER_SUFFIX = '\u0000'
const FENCED_RE = /(```|~~~)([\s\S]*?)\1/g
const FENCED_RE = /(^|\n)( {0,3})(`{3,}|~{3,})[^\n]*\n[\s\S]*?\n\2\3[ \t]*(?=\n|$)/g
const INLINE_CODE_RE = /`[^`\n]*`/g
function protectCodeBlocks(input: string): { masked: string; blocks: string[] } {
@@ -32,10 +32,20 @@ function protectCodeBlocks(input: string): { masked: string; blocks: string[] }
function restoreCodeBlocks(text: string, blocks: string[]): string {
if (blocks.length === 0) return text
return text.replace(
new RegExp(`${PLACEHOLDER_PREFIX}(\\d+)${PLACEHOLDER_SUFFIX}`, 'g'),
(_, idx) => blocks[Number(idx)] ?? '',
)
const placeholderRe = new RegExp(`${PLACEHOLDER_PREFIX}(\\d+)${PLACEHOLDER_SUFFIX}`, 'g')
let restored = text
for (let i = 0; i < blocks.length; i += 1) {
const next = restored.replace(
placeholderRe,
(_, idx) => blocks[Number(idx)] ?? '',
)
if (next === restored) break
restored = next
}
return restored
}
export function parseThinking(content: string, opts: ParseOptions): ParsedThinking {