refactor(CodeBlock): closed fence detection for html (#9424)

* refactor(CodeBlock): closed fence detection for html

* refactor: improve type, fix test

* doc: add comments
This commit is contained in:
one
2025-08-22 22:37:34 +08:00
committed by GitHub
parent ae203b5c7c
commit c2aff60127
3 changed files with 35 additions and 8 deletions

View File

@@ -3,7 +3,7 @@ import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import store from '@renderer/store'
import { messageBlocksSelectors } from '@renderer/store/messageBlock'
import { MessageBlockStatus } from '@renderer/types/newMessage'
import { getCodeBlockId } from '@renderer/utils/markdown'
import { getCodeBlockId, isOpenFenceBlock } from '@renderer/utils/markdown'
import type { Node } from 'mdast'
import React, { memo, useCallback, useMemo } from 'react'
@@ -16,8 +16,9 @@ interface Props {
}
const CodeBlock: React.FC<Props> = ({ children, className, node, blockId }) => {
const match = /language-([\w-+]+)/.exec(className || '') || children?.includes('\n')
const language = match?.[1] ?? 'text'
const languageMatch = /language-([\w-+]+)/.exec(className || '')
const isMultiline = children?.includes('\n')
const language = languageMatch?.[1] ?? (isMultiline ? 'text' : null)
// 代码块 id
const id = useMemo(() => getCodeBlockId(node?.position?.start), [node?.position?.start])
@@ -39,11 +40,11 @@ const CodeBlock: React.FC<Props> = ({ children, className, node, blockId }) => {
[blockId, id]
)
if (match) {
if (language !== null) {
// HTML 代码块特殊处理
// FIXME: 感觉没有必要用 isHtmlCode 判断
if (language === 'html') {
return <HtmlArtifactsCard html={children} onSave={handleSave} isStreaming={isStreaming} />
const isOpenFence = isOpenFenceBlock(children?.length, languageMatch?.[1]?.length, node?.position)
return <HtmlArtifactsCard html={children} onSave={handleSave} isStreaming={isStreaming && isOpenFence} />
}
return (

View File

@@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({
emit: vi.fn()
},
getCodeBlockId: vi.fn(),
isOpenFenceBlock: vi.fn(),
selectById: vi.fn(),
CodeBlockView: vi.fn(({ onSave, children }) => (
<div>
@@ -36,7 +37,8 @@ vi.mock('@renderer/services/EventService', () => ({
}))
vi.mock('@renderer/utils/markdown', () => ({
getCodeBlockId: mocks.getCodeBlockId
getCodeBlockId: mocks.getCodeBlockId,
isOpenFenceBlock: mocks.isOpenFenceBlock
}))
vi.mock('@renderer/store', () => ({
@@ -74,6 +76,7 @@ describe('CodeBlock', () => {
vi.clearAllMocks()
// Default mock return values
mocks.getCodeBlockId.mockReturnValue('test-code-block-id')
mocks.isOpenFenceBlock.mockReturnValue(false)
mocks.selectById.mockReturnValue({
id: 'test-msg-block-id',
status: MessageBlockStatus.SUCCESS

View File

@@ -2,6 +2,7 @@ import remarkParse from 'remark-parse'
import remarkStringify from 'remark-stringify'
import removeMarkdown from 'remove-markdown'
import { unified } from 'unified'
import type { Point, Position } from 'unist'
import { visit } from 'unist-util-visit'
/**
@@ -189,7 +190,7 @@ export function removeTrailingDoubleSpaces(markdown: string): string {
* @param start 代码块节点的起始位置
* @returns 代码块在 Markdown 字符串中的 ID
*/
export function getCodeBlockId(start: any): string | null {
export function getCodeBlockId(start?: Point): string | null {
return start ? `${start.line}:${start.column}:${start.offset}` : null
}
@@ -218,6 +219,28 @@ export function updateCodeBlock(raw: string, id: string, newContent: string): st
return unified().use(remarkStringify).stringify(tree)
}
/**
* 检查代码块是否包含 open fence。
* 限制:
* - 语言名不能包含空格,因为 remark-math 无法处理,会导致 end.offset 过长。
*
* 这个算法基于 remark/micromark 解析代码块的原理,所有参数实际上都可以从 node 中获取。
* 一个代码块的 node.position 包含 fences而 children 不包含 fences通过它们之间的
* 差值就可以判断有没有 closed fence。
*
* @param codeLength 代码长度(不包含语言信息)
* @param metaLength 元数据长度(```之后的语言信息)
* @param position 位置unist 节点位置)
* @returns 是否为 open fence 代码块
*/
export function isOpenFenceBlock(codeLength?: number, metaLength?: number, position?: Position): boolean {
const contentLength = (codeLength ?? 0) + (metaLength ?? 0)
const start = position?.start?.offset ?? 0
const end = position?.end?.offset ?? 0
// 余量至少是 fence (3) + newlines (2)
return end - start <= contentLength + 5
}
/**
* 检查代码是否具有HTML特征
* @param code 输入的代码字符串