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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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