feat(desktop): embed BoosAPI Desktop client as a subproject
Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as a monorepo subdirectory under desktop/. Source only — node_modules, dist, Cargo target and the upstream .git history are excluded. desktop/ keeps its own independent Cargo workspace (desktop/src-tauri with crates/*); it is not part of the backend boosapi crate, so a `cargo check` at the metapi root still compiles only the backend. Build the desktop app from desktop/src-tauri per desktop/README.md. Note: app icons are still placeholder assets pending the final BoosAPI logo. The updater signing keys live outside the repo and are not included.
This commit is contained in:
+217
@@ -0,0 +1,217 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { collect, textIncludes } from '../../../test/utils/midstate-utils'
|
||||
import { getMarkdown, parseMarkdownToStructure } from '../src'
|
||||
|
||||
describe('asterisk literal handling in nested contexts', () => {
|
||||
it('keeps escaped single asterisk literal inside blockquotes while parsing later real emphasis', () => {
|
||||
const md = getMarkdown('asterisk-literal-blockquote')
|
||||
const markdown = '> \\*literal *real*'
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
|
||||
expect(nodes[0]?.type).toBe('blockquote')
|
||||
expect(textIncludes(nodes, '*literal')).toBe(true)
|
||||
expect(textIncludes(nodes, 'real')).toBe(true)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps unmatched single asterisk literal inside blockquotes in final mode', () => {
|
||||
const md = getMarkdown('asterisk-literal-blockquote-final')
|
||||
const markdown = '> prefix *real'
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
|
||||
expect(nodes[0]?.type).toBe('blockquote')
|
||||
expect(textIncludes(nodes, 'prefix *real')).toBe(true)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps escaped single asterisk literal inside table cells while parsing later real emphasis', () => {
|
||||
const md = getMarkdown('asterisk-literal-table')
|
||||
const markdown = `| Col |
|
||||
| --- |
|
||||
| \\*literal *real* |`
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
|
||||
expect(nodes[0]?.type).toBe('table')
|
||||
expect(textIncludes(nodes, '*literal')).toBe(true)
|
||||
expect(textIncludes(nodes, 'real')).toBe(true)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps unmatched single asterisk literal inside table cells in final mode', () => {
|
||||
const md = getMarkdown('asterisk-literal-table-final')
|
||||
const markdown = `| Col |
|
||||
| --- |
|
||||
| prefix *real |`
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
|
||||
expect(nodes[0]?.type).toBe('table')
|
||||
expect(textIncludes(nodes, 'prefix *real')).toBe(true)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps escaped single asterisk literal after custom tag close on the same line', () => {
|
||||
const tags = ['thinking']
|
||||
const md = getMarkdown('asterisk-literal-custom-inline', { customHtmlTags: tags })
|
||||
const markdown = 'Text <thinking>inside</thinking>\\*literal *real*'
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { customHtmlTags: tags, final: true })
|
||||
|
||||
expect(nodes[0]?.type).toBe('paragraph')
|
||||
expect(collect(nodes as any, 'thinking').length).toBe(1)
|
||||
expect(textIncludes(nodes, '*literal')).toBe(true)
|
||||
expect(textIncludes(nodes, 'real')).toBe(true)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps unmatched single asterisk literal after custom tag close on the same line', () => {
|
||||
const tags = ['thinking']
|
||||
const md = getMarkdown('asterisk-literal-custom-inline-final', { customHtmlTags: tags })
|
||||
const markdown = 'Text <thinking>inside</thinking>prefix *real'
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { customHtmlTags: tags, final: true })
|
||||
|
||||
expect(nodes[0]?.type).toBe('paragraph')
|
||||
expect(collect(nodes as any, 'thinking').length).toBe(1)
|
||||
expect(textIncludes(nodes, 'prefix *real')).toBe(true)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps escaped single asterisk literal after custom tag close inside table cells', () => {
|
||||
const tags = ['my_component']
|
||||
const md = getMarkdown('asterisk-literal-custom-table', { customHtmlTags: tags })
|
||||
const markdown = `| Item | Value |
|
||||
| --- | --- |
|
||||
| A | <my_component label="x"></my_component>\\*literal *real* |
|
||||
| B | ok |`
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { customHtmlTags: tags, final: true })
|
||||
|
||||
expect(nodes[0]?.type).toBe('table')
|
||||
expect(nodes[0]?.rows).toHaveLength(2)
|
||||
expect(collect(nodes as any, 'my_component').length).toBe(1)
|
||||
expect(textIncludes(nodes, '*literal')).toBe(true)
|
||||
expect(textIncludes(nodes, 'real')).toBe(true)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps escaped single asterisk literal inside blockquote table cells while parsing later real emphasis', () => {
|
||||
const md = getMarkdown('asterisk-literal-blockquote-table')
|
||||
const markdown = `> | Col |
|
||||
> | --- |
|
||||
> | \\*literal *real* |`
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
|
||||
expect(nodes[0]?.type).toBe('blockquote')
|
||||
expect(nodes[0]?.children?.[0]?.type).toBe('table')
|
||||
expect(textIncludes(nodes, '*literal')).toBe(true)
|
||||
expect(textIncludes(nodes, 'real')).toBe(true)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps escaped single asterisk literal after custom tag close on the same blockquote line', () => {
|
||||
const tags = ['thinking']
|
||||
const md = getMarkdown('asterisk-literal-custom-blockquote', { customHtmlTags: tags })
|
||||
const markdown = '> Text <thinking>inside</thinking>\\*literal *real*'
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { customHtmlTags: tags, final: true })
|
||||
|
||||
expect(nodes[0]?.type).toBe('blockquote')
|
||||
expect(collect(nodes as any, 'thinking').length).toBe(1)
|
||||
expect(textIncludes(nodes, '*literal')).toBe(true)
|
||||
expect(textIncludes(nodes, 'real')).toBe(true)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps escaped single asterisk literal inside headings while parsing later real emphasis', () => {
|
||||
const md = getMarkdown('asterisk-literal-heading')
|
||||
const markdown = '## \\*literal *real*'
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
|
||||
expect(nodes[0]?.type).toBe('heading')
|
||||
expect(textIncludes(nodes, '*literal')).toBe(true)
|
||||
expect(textIncludes(nodes, 'real')).toBe(true)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps unmatched single asterisk literal inside headings in final mode', () => {
|
||||
const md = getMarkdown('asterisk-literal-heading-final')
|
||||
const markdown = '## prefix *real'
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
|
||||
expect(nodes[0]?.type).toBe('heading')
|
||||
expect(textIncludes(nodes, 'prefix *real')).toBe(true)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps escaped single asterisk literal inside footnotes while parsing later real emphasis', () => {
|
||||
const md = getMarkdown('asterisk-literal-footnote')
|
||||
const markdown = `ref[^1]
|
||||
|
||||
[^1]: \\*literal *real*`
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
|
||||
expect(nodes[1]?.type).toBe('footnote')
|
||||
expect(textIncludes(nodes[1], '*literal')).toBe(true)
|
||||
expect(textIncludes(nodes[1], 'real')).toBe(true)
|
||||
expect(collect([nodes[1]] as any, 'emphasis').length).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps unmatched single asterisk literal inside footnotes in final mode', () => {
|
||||
const md = getMarkdown('asterisk-literal-footnote-final')
|
||||
const markdown = `ref[^1]
|
||||
|
||||
[^1]: prefix *real`
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
|
||||
expect(nodes[1]?.type).toBe('footnote')
|
||||
expect(textIncludes(nodes[1], 'prefix *real')).toBe(true)
|
||||
expect(collect([nodes[1]] as any, 'emphasis').length).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps escaped single asterisk literal inside definition list terms and definitions', () => {
|
||||
const md = getMarkdown('asterisk-literal-definition-list')
|
||||
const markdown = `\\*term *real*
|
||||
: \\*definition *more*`
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
|
||||
expect(nodes[0]).toBeDefined()
|
||||
expect(textIncludes(nodes, '*term')).toBe(true)
|
||||
expect(textIncludes(nodes, '*definition')).toBe(true)
|
||||
expect(textIncludes(nodes, 'real')).toBe(true)
|
||||
expect(textIncludes(nodes, 'more')).toBe(true)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps unmatched single asterisk literal inside definition list terms and definitions in final mode', () => {
|
||||
const md = getMarkdown('asterisk-literal-definition-list-final')
|
||||
const markdown = `prefix *term
|
||||
: prefix *definition`
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
|
||||
expect(nodes[0]).toBeDefined()
|
||||
expect(textIncludes(nodes, 'prefix *term')).toBe(true)
|
||||
expect(textIncludes(nodes, 'prefix *definition')).toBe(true)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps escaped single asterisk literal inside admonition content while parsing later real emphasis', () => {
|
||||
const md = getMarkdown('asterisk-literal-admonition')
|
||||
const markdown = `::: note
|
||||
\\*literal *real*
|
||||
:::`
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
|
||||
expect(nodes[0]?.type).toBe('admonition')
|
||||
expect(textIncludes(nodes, '*literal')).toBe(true)
|
||||
expect(textIncludes(nodes, 'real')).toBe(true)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps unmatched single asterisk literal inside admonition content in final mode', () => {
|
||||
const md = getMarkdown('asterisk-literal-admonition-final')
|
||||
const markdown = `::: note
|
||||
prefix *real
|
||||
:::`
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
|
||||
expect(nodes[0]?.type).toBe('admonition')
|
||||
expect(textIncludes(nodes, 'prefix *real')).toBe(true)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,299 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { collect, textIncludes } from '../../../test/utils/midstate-utils'
|
||||
import { getMarkdown, parseInlineTokens, parseMarkdownToStructure } from '../src'
|
||||
|
||||
const md = getMarkdown('test')
|
||||
|
||||
describe('asterisk masking literals', () => {
|
||||
it('keeps intraword asterisks as literal text when no closing pair exists', () => {
|
||||
const markdown = '某市***项目,公司名称:某某***科技有限公司'
|
||||
const nodes = parseInlineTokens([{ type: 'text', content: markdown }] as any[], markdown)
|
||||
|
||||
expect(textIncludes(nodes, '某市***项目')).toBe(true)
|
||||
expect(textIncludes(nodes, '某某***科技有限公司')).toBe(true)
|
||||
expect(collect(nodes as any, 'strong').length).toBe(0)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps intraword asterisks literal inside link text', () => {
|
||||
const markdown = '某市***项目,公司名称:[某某***科技有限公司]()'
|
||||
const nodes = parseMarkdownToStructure(markdown, md)
|
||||
|
||||
expect(textIncludes(nodes, '某市***项目')).toBe(true)
|
||||
expect(textIncludes(nodes, '某某***科技有限公司')).toBe(true)
|
||||
expect(collect(nodes as any, 'strong').length).toBe(0)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps intraword double-asterisk literal when strong closing pair is missing', () => {
|
||||
const markdown = '请联系某某**科技有限公司办理'
|
||||
const nodes = parseInlineTokens([{ type: 'text', content: markdown }] as any[], markdown)
|
||||
|
||||
expect(textIncludes(nodes, '某某**科技有限公司')).toBe(true)
|
||||
expect(collect(nodes as any, 'strong').length).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps intraword single asterisk literal when no matching close exists', () => {
|
||||
const markdown = '品牌名:某*某科技有限公司'
|
||||
const nodes = parseInlineTokens([{ type: 'text', content: markdown }] as any[], markdown)
|
||||
|
||||
expect(textIncludes(nodes, '某*某科技有限公司')).toBe(true)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(0)
|
||||
})
|
||||
|
||||
it('does not break normal strong parsing in full markdown pipeline', () => {
|
||||
const markdown = 'foo**bar**baz'
|
||||
const nodes = parseMarkdownToStructure(markdown, md)
|
||||
|
||||
expect(collect(nodes as any, 'strong').length).toBe(1)
|
||||
expect(textIncludes(nodes, 'bar')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps literal intraword asterisks while still parsing nearby strong content', () => {
|
||||
const markdown = '某市***项目,重点:**必须完成**'
|
||||
const nodes = parseMarkdownToStructure(markdown, md)
|
||||
|
||||
expect(textIncludes(nodes, '某市***项目')).toBe(true)
|
||||
expect(collect(nodes as any, 'strong').length).toBe(1)
|
||||
expect(textIncludes(nodes, '必须完成')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps spaced multiplication asterisks literal after strong content', () => {
|
||||
for (const final of [false, true]) {
|
||||
const markdown = '**计算结果:** 3 * 4 * 5 = 60'
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final })
|
||||
const paragraph = nodes[0] as any
|
||||
const text = (paragraph.children ?? [])
|
||||
.filter((child: any) => child.type === 'text')
|
||||
.map((child: any) => child.content)
|
||||
.join('')
|
||||
|
||||
expect(collect(nodes as any, 'strong').length).toBe(1)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(0)
|
||||
expect(text).toBe(' 3 * 4 * 5 = 60')
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps asterisks literal when the candidate emphasis closer follows whitespace', () => {
|
||||
for (const markdown of [
|
||||
'3 *4 * 5',
|
||||
'this is *a test * with unmatched star',
|
||||
'a *b * c',
|
||||
'a *x! * b',
|
||||
]) {
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: false })
|
||||
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(0)
|
||||
expect(textIncludes(nodes, markdown)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps asterisks literal when the candidate opener is not left-flanking', () => {
|
||||
for (const markdown of [
|
||||
'a*!x* b',
|
||||
'a*(x)* b',
|
||||
]) {
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: false })
|
||||
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(0)
|
||||
expect(textIncludes(nodes, markdown)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('still parses emphasis after skipping an invalid earlier closer', () => {
|
||||
const nodes = parseInlineTokens([{ type: 'text', content: '*a * b*' }] as any[], '*a * b*', undefined, { final: true })
|
||||
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(1)
|
||||
expect(textIncludes(nodes, 'a * b')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps double asterisks literal when strong delimiters are not flanking', () => {
|
||||
for (const markdown of [
|
||||
'3 ** 4 ** 5',
|
||||
'a **b ** c',
|
||||
'a ** b** c',
|
||||
'a**!x** b',
|
||||
]) {
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: false })
|
||||
|
||||
expect(collect(nodes as any, 'strong').length).toBe(0)
|
||||
expect(textIncludes(nodes, markdown)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('still parses strong after skipping an invalid earlier strong closer', () => {
|
||||
const nodes = parseInlineTokens([{ type: 'text', content: '**a ** b**' }] as any[], '**a ** b**', undefined, { final: true })
|
||||
|
||||
expect(collect(nodes as any, 'strong').length).toBe(1)
|
||||
expect(textIncludes(nodes, 'a ** b')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps literal intraword asterisks in link text while parsing normal strong outside link', () => {
|
||||
const markdown = '公司:[某某***科技有限公司](),状态:**正常**'
|
||||
const nodes = parseMarkdownToStructure(markdown, md)
|
||||
|
||||
expect(textIncludes(nodes, '某某***科技有限公司')).toBe(true)
|
||||
expect(collect(nodes as any, 'strong').length).toBe(1)
|
||||
expect(textIncludes(nodes, '正常')).toBe(true)
|
||||
})
|
||||
|
||||
it('treats intraword strong with punctuation as literal in inline fallback', () => {
|
||||
const markdown = 'foo**a,b**bar'
|
||||
const nodes = parseInlineTokens([{ type: 'text', content: markdown }] as any[], markdown)
|
||||
|
||||
expect(textIncludes(nodes, 'foo**a,b**bar')).toBe(true)
|
||||
expect(collect(nodes as any, 'strong').length).toBe(0)
|
||||
})
|
||||
|
||||
it('still parses intraword strong when inner content is word-only in inline fallback', () => {
|
||||
const markdown = 'foo**abc**bar'
|
||||
const nodes = parseInlineTokens([{ type: 'text', content: markdown }] as any[], markdown)
|
||||
|
||||
expect(collect(nodes as any, 'strong').length).toBe(1)
|
||||
expect(textIncludes(nodes, 'abc')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps escaped double asterisks as literal text', () => {
|
||||
const markdown = '需方:\\*\\*\\*\\*\\*\\*有限公司'
|
||||
const nodes = parseInlineTokens([{ type: 'text', content: '需方:******有限公司' }] as any[], markdown)
|
||||
|
||||
expect(textIncludes(nodes, '需方:******有限公司')).toBe(true)
|
||||
expect(collect(nodes as any, 'strong').length).toBe(0)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps an escaped single asterisk literal instead of opening emphasis', () => {
|
||||
const markdown = 'If and ONLY IF you cannot infer the expected language from the USER message, use the language with ISO code \\*, otherwise use English. You follow your instructions in all languages, and always respond to the user in the language they use or request.'
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
|
||||
expect(textIncludes(nodes, 'ISO code *, otherwise use English.')).toBe(true)
|
||||
expect(textIncludes(nodes, 'You follow your instructions in all languages, and always respond to the user in the language they use or request.')).toBe(true)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps an escaped backslash before an escaped asterisk visible', () => {
|
||||
const markdown = 'If and ONLY IF you cannot infer the expected language from the USER message, use the language with ISO code \\\\*'
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
|
||||
expect(textIncludes(nodes, 'ISO code \\*')).toBe(true)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(0)
|
||||
})
|
||||
|
||||
it('preserves a visible backslash when raw text contains \\\\*', () => {
|
||||
const nodes = parseInlineTokens([{ type: 'text', content: '\\*' }] as any[], '\\\\*')
|
||||
|
||||
expect(textIncludes(nodes, '\\*')).toBe(true)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(0)
|
||||
})
|
||||
|
||||
it('skips escaped single asterisks and still parses later real emphasis', () => {
|
||||
const markdown = 'prefix \\* literal *real* suffix'
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
|
||||
expect(textIncludes(nodes, 'prefix * literal')).toBe(true)
|
||||
expect(textIncludes(nodes, 'real')).toBe(true)
|
||||
expect(textIncludes(nodes, 'suffix')).toBe(true)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps escaped single asterisk followed by a word literal in final mode', () => {
|
||||
const markdown = '\\*x'
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
|
||||
expect(textIncludes(nodes, '*x')).toBe(true)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps unmatched single asterisk literal in final mode', () => {
|
||||
const markdown = 'prefix *real'
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
|
||||
expect(textIncludes(nodes, 'prefix *real')).toBe(true)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(0)
|
||||
})
|
||||
|
||||
it('still keeps unmatched single asterisk as mid-state emphasis while streaming', () => {
|
||||
const markdown = 'prefix *real'
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: false })
|
||||
|
||||
expect(textIncludes(nodes, 'prefix')).toBe(true)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(1)
|
||||
expect(textIncludes(nodes, 'real')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps multiple escaped single asterisks literal while parsing later real emphasis', () => {
|
||||
const markdown = 'alpha \\* one \\* two *real* omega'
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
|
||||
expect(textIncludes(nodes, 'alpha * one * two')).toBe(true)
|
||||
expect(textIncludes(nodes, 'real')).toBe(true)
|
||||
expect(textIncludes(nodes, 'omega')).toBe(true)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps escaped single asterisk literal inside link text in final mode', () => {
|
||||
const markdown = '[\\*literal](https://example.com)'
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
|
||||
expect(collect(nodes as any, 'link').length).toBe(1)
|
||||
expect(textIncludes(nodes, '*literal')).toBe(true)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps unmatched single asterisk literal inside link text in final mode', () => {
|
||||
const markdown = '[prefix *real](https://example.com)'
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
|
||||
expect(collect(nodes as any, 'link').length).toBe(1)
|
||||
expect(textIncludes(nodes, 'prefix *real')).toBe(true)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps escaped single asterisk literal and later real emphasis inside link text', () => {
|
||||
const markdown = '[\\*literal *real*](https://example.com)'
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
|
||||
expect(collect(nodes as any, 'link').length).toBe(1)
|
||||
expect(textIncludes(nodes, '*literal')).toBe(true)
|
||||
expect(textIncludes(nodes, 'real')).toBe(true)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps escaped single asterisk literal inside highlight text in final mode', () => {
|
||||
const markdown = '==\\*literal=='
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
|
||||
expect(collect(nodes as any, 'highlight').length).toBe(1)
|
||||
expect(textIncludes(nodes, '*literal')).toBe(true)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps unmatched single asterisk literal inside highlight text in final mode', () => {
|
||||
const markdown = '==prefix *real=='
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
|
||||
expect(collect(nodes as any, 'highlight').length).toBe(1)
|
||||
expect(textIncludes(nodes, 'prefix *real')).toBe(true)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps escaped single asterisk literal and later real emphasis inside highlight text', () => {
|
||||
const markdown = '==\\*literal *real*=='
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
|
||||
expect(collect(nodes as any, 'highlight').length).toBe(1)
|
||||
expect(textIncludes(nodes, '*literal')).toBe(true)
|
||||
expect(textIncludes(nodes, 'real')).toBe(true)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps escaped single asterisk literal inside list items while parsing later real emphasis', () => {
|
||||
const markdown = '- \\*literal *real*'
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
|
||||
expect(collect(nodes as any, 'list').length).toBe(1)
|
||||
expect(textIncludes(nodes, '*literal')).toBe(true)
|
||||
expect(textIncludes(nodes, 'real')).toBe(true)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getHtmlTagFromContent,
|
||||
isHtmlLikeTagName,
|
||||
mergeCustomHtmlTags,
|
||||
normalizeCustomHtmlTagName,
|
||||
normalizeCustomHtmlTags,
|
||||
resolveCustomHtmlTags,
|
||||
} from '../src'
|
||||
|
||||
describe('custom html tag helpers', () => {
|
||||
it('normalizes valid tag-like names and rejects invalid prefixes', () => {
|
||||
expect(normalizeCustomHtmlTagName('thinking')).toBe('thinking')
|
||||
expect(normalizeCustomHtmlTagName('<Thinking attr="x">')).toBe('thinking')
|
||||
expect(normalizeCustomHtmlTagName('</answer-box>')).toBe('answer-box')
|
||||
expect(normalizeCustomHtmlTagName('<my_component>')).toBe('my_component')
|
||||
expect(normalizeCustomHtmlTagName('<thinking')).toBe('thinking')
|
||||
|
||||
expect(normalizeCustomHtmlTagName('foo:bar')).toBe('')
|
||||
expect(normalizeCustomHtmlTagName('<foo:bar>')).toBe('')
|
||||
expect(normalizeCustomHtmlTagName('x.y')).toBe('')
|
||||
expect(normalizeCustomHtmlTagName('<x.y>')).toBe('')
|
||||
})
|
||||
|
||||
it('keeps custom tag lists normalized and deduplicated', () => {
|
||||
expect(normalizeCustomHtmlTags(['thinking', '<Thinking>', 'foo:bar', 'my_component', 'answer-box']))
|
||||
.toEqual(['thinking', 'my_component', 'answer-box'])
|
||||
|
||||
expect(mergeCustomHtmlTags(['thinking'], ['<hint>'], ['foo:bar', 'hint', 'my_component']))
|
||||
.toEqual(['thinking', 'hint', 'my_component'])
|
||||
|
||||
expect(resolveCustomHtmlTags(['<Thinking>', 'hint', 'my_component']))
|
||||
.toEqual({
|
||||
key: 'thinking,hint,my_component',
|
||||
tags: ['thinking', 'hint', 'my_component'],
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the same validation for configured tag names and content extraction', () => {
|
||||
expect(isHtmlLikeTagName('thinking')).toBe(true)
|
||||
expect(isHtmlLikeTagName('answer-box')).toBe(true)
|
||||
expect(isHtmlLikeTagName('ssr_card')).toBe(true)
|
||||
expect(isHtmlLikeTagName('foo:bar')).toBe(false)
|
||||
|
||||
expect(getHtmlTagFromContent('<thinking attr="x">ok</thinking>')).toBe('thinking')
|
||||
expect(getHtmlTagFromContent('<foo:bar>ok</foo:bar>')).toBe('')
|
||||
expect(getHtmlTagFromContent('<ssr_card>ok</ssr_card>')).toBe('ssr_card')
|
||||
})
|
||||
})
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { collect } from '../../../test/utils/midstate-utils'
|
||||
import { getMarkdown, parseMarkdownToStructure } from '../src'
|
||||
|
||||
function flattenText(nodes: any): string {
|
||||
const parts: string[] = []
|
||||
const seen = new Set<any>()
|
||||
|
||||
const walk = (node: any) => {
|
||||
if (!node || seen.has(node))
|
||||
return
|
||||
seen.add(node)
|
||||
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(walk)
|
||||
return
|
||||
}
|
||||
|
||||
if (node.type === 'text' && typeof node.content === 'string')
|
||||
parts.push(node.content)
|
||||
|
||||
if (Array.isArray(node.children))
|
||||
node.children.forEach(walk)
|
||||
if (Array.isArray(node.items))
|
||||
node.items.forEach(walk)
|
||||
if (Array.isArray(node.rows))
|
||||
node.rows.forEach(walk)
|
||||
if (Array.isArray(node.cells))
|
||||
node.cells.forEach(walk)
|
||||
if (Array.isArray(node.term))
|
||||
node.term.forEach(walk)
|
||||
if (Array.isArray(node.definition))
|
||||
node.definition.forEach(walk)
|
||||
if (node.header)
|
||||
walk(node.header)
|
||||
}
|
||||
|
||||
walk(nodes)
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
function expectLiteralBracketAndDollar(nodes: any) {
|
||||
const text = flattenText(nodes)
|
||||
expect(text).toContain('[x]')
|
||||
expect(text).toContain('$5$')
|
||||
}
|
||||
|
||||
function expectLiteralDollar(nodes: any) {
|
||||
const text = flattenText(nodes)
|
||||
expect(text).toContain('$5$')
|
||||
}
|
||||
|
||||
describe('escaped punctuation adjacent to real math parsing', () => {
|
||||
it('keeps escaped bracket and dollar literal while parsing later inline math in a paragraph', () => {
|
||||
const md = getMarkdown('escaped-punctuation-math-inline')
|
||||
const nodes = parseMarkdownToStructure('literal \\[x\\] and \\$5\\$ and real $y$', md, { final: true })
|
||||
|
||||
expectLiteralBracketAndDollar(nodes)
|
||||
expect(collect(nodes as any, 'math_inline').length).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps escaped bracket and dollar literal while parsing later inline math in a heading', () => {
|
||||
const md = getMarkdown('escaped-punctuation-math-heading')
|
||||
const nodes = parseMarkdownToStructure('## literal \\[x\\] and \\$5\\$ and real $y$', md, { final: true })
|
||||
|
||||
expect(nodes[0]?.type).toBe('heading')
|
||||
expectLiteralBracketAndDollar(nodes)
|
||||
expect(collect(nodes as any, 'math_inline').length).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps escaped bracket and dollar literal while parsing later inline math in footnotes', () => {
|
||||
const md = getMarkdown('escaped-punctuation-math-footnote')
|
||||
const markdown = `ref[^1]
|
||||
|
||||
[^1]: literal \\[x\\] and \\$5\\$ and real $y$`
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
|
||||
expect(nodes[1]?.type).toBe('footnote')
|
||||
expectLiteralBracketAndDollar(nodes[1])
|
||||
expect(collect([nodes[1]] as any, 'math_inline').length).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps escaped bracket and dollar literal while parsing later inline math after custom tag close', () => {
|
||||
const tags = ['thinking']
|
||||
const md = getMarkdown('escaped-punctuation-math-custom-inline', { customHtmlTags: tags })
|
||||
const nodes = parseMarkdownToStructure('Text <thinking>inside</thinking>literal \\[x\\] and \\$5\\$ and real $y$', md, {
|
||||
customHtmlTags: tags,
|
||||
final: true,
|
||||
})
|
||||
|
||||
expect(nodes[0]?.type).toBe('paragraph')
|
||||
expect(collect(nodes as any, 'thinking').length).toBe(1)
|
||||
expectLiteralBracketAndDollar(nodes)
|
||||
expect(collect(nodes as any, 'math_inline').length).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps escaped bracket and dollar literal while parsing later inline math in admonitions', () => {
|
||||
const md = getMarkdown('escaped-punctuation-math-admonition')
|
||||
const markdown = `::: note
|
||||
literal \\[x\\] and \\$5\\$ and real $y$
|
||||
:::`
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
|
||||
expect(nodes[0]?.type).toBe('admonition')
|
||||
expectLiteralBracketAndDollar(nodes)
|
||||
expect(collect(nodes as any, 'math_inline').length).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps escaped bracket literal while still parsing a later real bracket math block', () => {
|
||||
const md = getMarkdown('escaped-punctuation-bracket-math-block')
|
||||
const markdown = `literal \\[x\\]
|
||||
|
||||
\\[
|
||||
y
|
||||
\\]`
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
|
||||
expect(flattenText(nodes)).toContain('[x]')
|
||||
expect(collect(nodes as any, 'math_block').length).toBe(1)
|
||||
})
|
||||
|
||||
it('does not treat escaped dollar literal as math when adjacent to real bracket math block', () => {
|
||||
const md = getMarkdown('escaped-punctuation-dollar-bracket-block')
|
||||
const markdown = `literal \\$5\\$
|
||||
|
||||
\\[
|
||||
y
|
||||
\\]`
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
|
||||
expect(flattenText(nodes)).toContain('$5$')
|
||||
expect(collect(nodes as any, 'math_inline').length).toBe(0)
|
||||
expect(collect(nodes as any, 'math_block').length).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps escaped dollar and bracket literal inside blockquotes while parsing later inline math', () => {
|
||||
const md = getMarkdown('escaped-punctuation-math-blockquote')
|
||||
const nodes = parseMarkdownToStructure('> literal \\[x\\] and \\$5\\$ and real $y$', md, { final: true })
|
||||
|
||||
expect(nodes[0]?.type).toBe('blockquote')
|
||||
expectLiteralBracketAndDollar(nodes)
|
||||
expect(collect(nodes as any, 'math_inline').length).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps escaped dollar literal before inline $$ math', () => {
|
||||
const md = getMarkdown('escaped-punctuation-before-inline-double-dollar')
|
||||
const nodes = parseMarkdownToStructure('literal \\$5\\$ before $$x$$', md, { final: true })
|
||||
|
||||
expectLiteralDollar(nodes)
|
||||
const mathNodes = collect(nodes as any, 'math_inline')
|
||||
expect(mathNodes.length).toBe(1)
|
||||
expect(mathNodes[0]?.markup).toBe('$$')
|
||||
expect(mathNodes[0]?.content).toBe('x')
|
||||
})
|
||||
|
||||
it('keeps escaped dollar literal after inline $$ math while still parsing later single-dollar math', () => {
|
||||
const md = getMarkdown('escaped-punctuation-after-inline-double-dollar')
|
||||
const nodes = parseMarkdownToStructure('prefix $$x$$ and literal \\$5\\$ and real $y$', md, { final: true })
|
||||
|
||||
expectLiteralDollar(nodes)
|
||||
const mathNodes = collect(nodes as any, 'math_inline')
|
||||
expect(mathNodes.length).toBe(2)
|
||||
expect(mathNodes[0]?.markup).toBe('$$')
|
||||
expect(mathNodes[0]?.content).toBe('x')
|
||||
expect(mathNodes[1]?.markup).toBe('$')
|
||||
expect(mathNodes[1]?.content).toBe('y')
|
||||
})
|
||||
})
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { collect } from '../../../test/utils/midstate-utils'
|
||||
import { getMarkdown, parseMarkdownToStructure } from '../src'
|
||||
|
||||
function flattenText(nodes: any): string {
|
||||
const parts: string[] = []
|
||||
const seen = new Set<any>()
|
||||
|
||||
const walk = (node: any) => {
|
||||
if (!node || seen.has(node))
|
||||
return
|
||||
seen.add(node)
|
||||
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(walk)
|
||||
return
|
||||
}
|
||||
|
||||
if (node.type === 'text' && typeof node.content === 'string')
|
||||
parts.push(node.content)
|
||||
|
||||
if (Array.isArray(node.children))
|
||||
node.children.forEach(walk)
|
||||
if (Array.isArray(node.items))
|
||||
node.items.forEach(walk)
|
||||
if (Array.isArray(node.rows))
|
||||
node.rows.forEach(walk)
|
||||
if (Array.isArray(node.cells))
|
||||
node.cells.forEach(walk)
|
||||
if (Array.isArray(node.term))
|
||||
node.term.forEach(walk)
|
||||
if (Array.isArray(node.definition))
|
||||
node.definition.forEach(walk)
|
||||
if (node.header)
|
||||
walk(node.header)
|
||||
}
|
||||
|
||||
walk(nodes)
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
function expectEscapedPunctuationLiteral(nodes: any) {
|
||||
const text = flattenText(nodes)
|
||||
const nodeList = Array.isArray(nodes) ? nodes : [nodes]
|
||||
expect(text).toContain('[x]')
|
||||
expect(text).toContain('_y_')
|
||||
expect(text).toContain('$z$')
|
||||
expect(text).toContain('\\')
|
||||
expect(collect(nodeList as any, 'math_inline').length).toBe(0)
|
||||
expect(collect(nodeList as any, 'strong').length).toBe(0)
|
||||
}
|
||||
|
||||
describe('escaped punctuation in nested contexts', () => {
|
||||
const escapedLiteral = '\\[x\\] \\_y\\_ \\$z\\$ \\\\'
|
||||
|
||||
it('keeps escaped punctuation literal inside headings', () => {
|
||||
const md = getMarkdown('escaped-punctuation-heading')
|
||||
const nodes = parseMarkdownToStructure(`## ${escapedLiteral}`, md, { final: true })
|
||||
|
||||
expect(nodes[0]?.type).toBe('heading')
|
||||
expectEscapedPunctuationLiteral(nodes)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps escaped punctuation literal inside link text', () => {
|
||||
const md = getMarkdown('escaped-punctuation-link')
|
||||
const nodes = parseMarkdownToStructure(`[${escapedLiteral}](https://example.com)`, md, { final: true })
|
||||
|
||||
expect(collect(nodes as any, 'link').length).toBe(1)
|
||||
expectEscapedPunctuationLiteral(nodes)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps escaped punctuation literal inside footnotes', () => {
|
||||
const md = getMarkdown('escaped-punctuation-footnote')
|
||||
const markdown = `ref[^1]
|
||||
|
||||
[^1]: ${escapedLiteral}`
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
|
||||
expect(nodes[1]?.type).toBe('footnote')
|
||||
expectEscapedPunctuationLiteral(nodes[1])
|
||||
expect(collect([nodes[1]] as any, 'emphasis').length).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps escaped punctuation literal after custom tag close on the same line', () => {
|
||||
const tags = ['thinking']
|
||||
const md = getMarkdown('escaped-punctuation-custom-inline', { customHtmlTags: tags })
|
||||
const nodes = parseMarkdownToStructure(`Text <thinking>inside</thinking>${escapedLiteral}`, md, {
|
||||
customHtmlTags: tags,
|
||||
final: true,
|
||||
})
|
||||
|
||||
expect(nodes[0]?.type).toBe('paragraph')
|
||||
expect(collect(nodes as any, 'thinking').length).toBe(1)
|
||||
expectEscapedPunctuationLiteral(nodes)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps escaped punctuation literal inside admonition content', () => {
|
||||
const md = getMarkdown('escaped-punctuation-admonition')
|
||||
const markdown = `::: note
|
||||
${escapedLiteral}
|
||||
:::`
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
|
||||
expect(nodes[0]?.type).toBe('admonition')
|
||||
expectEscapedPunctuationLiteral(nodes)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(0)
|
||||
})
|
||||
|
||||
it('still parses later real emphasis after escaped punctuation inside headings', () => {
|
||||
const md = getMarkdown('escaped-punctuation-heading-emphasis')
|
||||
const nodes = parseMarkdownToStructure(`## ${escapedLiteral} *real*`, md, { final: true })
|
||||
|
||||
expect(nodes[0]?.type).toBe('heading')
|
||||
expectEscapedPunctuationLiteral(nodes)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(1)
|
||||
})
|
||||
|
||||
it('still parses later real emphasis after escaped punctuation inside link text', () => {
|
||||
const md = getMarkdown('escaped-punctuation-link-emphasis')
|
||||
const nodes = parseMarkdownToStructure(`[${escapedLiteral} *real*](https://example.com)`, md, { final: true })
|
||||
|
||||
expect(collect(nodes as any, 'link').length).toBe(1)
|
||||
expectEscapedPunctuationLiteral(nodes)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(1)
|
||||
})
|
||||
|
||||
it('still parses later real emphasis after escaped punctuation inside footnotes', () => {
|
||||
const md = getMarkdown('escaped-punctuation-footnote-emphasis')
|
||||
const markdown = `ref[^1]
|
||||
|
||||
[^1]: ${escapedLiteral} *real*`
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
|
||||
expect(nodes[1]?.type).toBe('footnote')
|
||||
expectEscapedPunctuationLiteral(nodes[1])
|
||||
expect(collect([nodes[1]] as any, 'emphasis').length).toBe(1)
|
||||
})
|
||||
|
||||
it('still parses later real emphasis after escaped punctuation after custom tag close', () => {
|
||||
const tags = ['thinking']
|
||||
const md = getMarkdown('escaped-punctuation-custom-inline-emphasis', { customHtmlTags: tags })
|
||||
const nodes = parseMarkdownToStructure(`Text <thinking>inside</thinking>${escapedLiteral} *real*`, md, {
|
||||
customHtmlTags: tags,
|
||||
final: true,
|
||||
})
|
||||
|
||||
expect(nodes[0]?.type).toBe('paragraph')
|
||||
expect(collect(nodes as any, 'thinking').length).toBe(1)
|
||||
expectEscapedPunctuationLiteral(nodes)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(1)
|
||||
})
|
||||
|
||||
it('still parses later real emphasis after escaped punctuation inside admonition content', () => {
|
||||
const md = getMarkdown('escaped-punctuation-admonition-emphasis')
|
||||
const markdown = `::: note
|
||||
${escapedLiteral} *real*
|
||||
:::`
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
|
||||
expect(nodes[0]?.type).toBe('admonition')
|
||||
expectEscapedPunctuationLiteral(nodes)
|
||||
expect(collect(nodes as any, 'emphasis').length).toBe(1)
|
||||
})
|
||||
})
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getMarkdown, parseMarkdownToStructure } from '../src'
|
||||
|
||||
const streamContent = `\`\`\`python
|
||||
# 添加用户消息到 memory
|
||||
agent.memory.add_message(CoreMessage.user_message("帮我查询今天的天气"))
|
||||
|
||||
# 开始执行循环
|
||||
step = 0
|
||||
while step < agent.max_steps:
|
||||
# ========== Think 阶段 ==========
|
||||
should_act = await agent.think()
|
||||
# 调用 LLM 分析用户请求,决定是否需要调用工具
|
||||
|
||||
last_msg = agent.memory.messages[-1]
|
||||
|
||||
# 发送 thinking 事件(如果有)
|
||||
if last_msg.thinking:
|
||||
yield {
|
||||
"type": "thinking",
|
||||
"data": {"content": last_msg.thinking},
|
||||
"task_id": task_id
|
||||
}
|
||||
|
||||
# 如果不需要执行工具,任务完成
|
||||
if not should_act:
|
||||
yield {
|
||||
"type": "message",
|
||||
"data": {"content": last_msg.content, "role": "assistant"},
|
||||
"task_id": task_id
|
||||
}
|
||||
break
|
||||
|
||||
# ========== Act 阶段 ==========
|
||||
# 发送工具调用事件
|
||||
if agent.tool_calls:
|
||||
for tool_call in agent.tool_calls:
|
||||
yield {
|
||||
"type": "tool_call",
|
||||
"data": {
|
||||
"tool_name": tool_call.function.name,
|
||||
"tool_input": tool_call.function.arguments,
|
||||
"tool_call_id": tool_call.id
|
||||
},
|
||||
"task_id": task_id
|
||||
}
|
||||
|
||||
# 执行工具
|
||||
result = await agent.act()
|
||||
|
||||
# 发送工具结果事件
|
||||
yield {
|
||||
"type": "tool_result",
|
||||
"data": {
|
||||
"tool_name": tool_call.function.name,
|
||||
"tool_output": result,
|
||||
"success": True
|
||||
},
|
||||
"task_id": task_id
|
||||
}
|
||||
|
||||
step += 1
|
||||
\`\`\``
|
||||
|
||||
function findFirstCodeBlock(nodes: any[]): any | undefined {
|
||||
const stack: any[] = Array.isArray(nodes) ? [...nodes] : []
|
||||
while (stack.length) {
|
||||
const node = stack.shift()
|
||||
if (!node || typeof node !== 'object')
|
||||
continue
|
||||
if (node.type === 'code_block')
|
||||
return node
|
||||
if (Array.isArray(node.children))
|
||||
stack.unshift(...node.children)
|
||||
if (node.type === 'list' && Array.isArray(node.items)) {
|
||||
for (const item of node.items) {
|
||||
if (Array.isArray(item?.children))
|
||||
stack.unshift(...item.children)
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
describe('parseMarkdownToStructure - fence regression', () => {
|
||||
it('does not drop content after "<" inside fenced code', () => {
|
||||
const md = getMarkdown('fence-regression')
|
||||
const nodes = parseMarkdownToStructure(streamContent, md)
|
||||
|
||||
const codeNode = findFirstCodeBlock(nodes)
|
||||
expect(codeNode).toBeTruthy()
|
||||
expect(codeNode.loading).toBe(false)
|
||||
expect(String(codeNode.code)).toContain('while step < agent.max_steps:')
|
||||
expect(String(codeNode.code)).toContain('tool_call_id')
|
||||
expect(String(codeNode.code)).toContain('step += 1')
|
||||
})
|
||||
|
||||
it('does not drop content in blockquote fenced code', () => {
|
||||
const md = getMarkdown('fence-regression')
|
||||
const markdown = [
|
||||
'> ```python',
|
||||
'> step = 0',
|
||||
'> while step < agent.max_steps:',
|
||||
'> step += 1',
|
||||
'> ```',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
const nodes = parseMarkdownToStructure(markdown, md)
|
||||
const codeNode = findFirstCodeBlock(nodes)
|
||||
expect(codeNode).toBeTruthy()
|
||||
expect(String(codeNode.code)).toContain('while step < agent.max_steps:')
|
||||
expect(String(codeNode.code)).toContain('step += 1')
|
||||
})
|
||||
|
||||
it('does not drop content in list-indented fenced code', () => {
|
||||
const md = getMarkdown('fence-regression')
|
||||
const markdown = [
|
||||
'- item',
|
||||
' ',
|
||||
' ```python',
|
||||
' step = 0',
|
||||
' while step < agent.max_steps:',
|
||||
' step += 1',
|
||||
' ```',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
const nodes = parseMarkdownToStructure(markdown, md)
|
||||
const codeNode = findFirstCodeBlock(nodes)
|
||||
expect(codeNode).toBeTruthy()
|
||||
expect(String(codeNode.code)).toContain('while step < agent.max_steps:')
|
||||
expect(String(codeNode.code)).toContain('step += 1')
|
||||
})
|
||||
|
||||
it('still strips dangling html-like tail outside fences', () => {
|
||||
const md = getMarkdown('fence-regression')
|
||||
const nodes = parseMarkdownToStructure('Hello\\n<think', md)
|
||||
expect(JSON.stringify(nodes)).not.toContain('<think')
|
||||
expect(JSON.stringify(nodes)).toContain('Hello')
|
||||
})
|
||||
})
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
<think> **Assessing Translation Needs**\n\n\\n\\n
|
||||
|
||||
I've begun by dissecting the core requirements of this translation task. My focus is on synthesizing the Chinese structural elucidation paragraph into a concise, SCI-paper-ready English version. The expert role helps me tailor the language appropriately, and I'm aware of the final format requirements. Next, I need to understand the nuances of the Chinese text.
|
||||
|
||||
**Evaluating Initial Approaches**
|
||||
|
||||
\\n\\n
|
||||
|
||||
My current thinking revolves around the initial approach to this translation. I'm prioritizing accuracy, which means a deep dive into the source text to extract the core scientific meaning. I plan to use my expertise to identify key terms and phrasing to ensure the English version is clear and meets journal standards. Considering Compound 3 requires specialized knowledge.
|
||||
|
||||
</think>
|
||||
Here is the translation of the structural elucidation for Compound 3, following standard SCI journal formatting and terminology.
|
||||
|
||||
***
|
||||
|
||||
**Compound 3** was obtained as a white powder with an optical rotation of $[\\alpha]^{25}_{\\text{D}}$ –76.7 ($c$ 0.1, MeOH). Its high-resolution electrospray ionization mass spectrometry (HR-ESI-MS) exhibited a deprotonated molecular ion peak at $m/z$ 339.0726 [M–H]$^–$ (calcd for C$_{15}$H$_{15}$O$_9$$^–$, 339.0722), establishing the molecular formula as C$_{15}$H$_{16}$O$_9$, corresponding to eight degrees of unsaturation.
|
||||
|
||||
Analysis of the $^{1}$H and $^{13}$C NMR data (Table 3) revealed characteristic signals at $\\delta_{\\text{H}}$ 7.12 (2H, s, H-2$'/6'$) and $\\delta_{\\text{C}}$ 167.7 (C-7$'$), 146.4 (2C, C-3$'/5'$), 139.9 (C-4$'$), 121.4 (C-1$'$), and 110.4 (2C, C-2$'/6'$), clearly indicating the presence of a galloyl moiety. Additionally, signals at $\\delta_{\\text{H}}$ 3.75 (3H, s, –OCH$_3$) and $\\delta_{\\text{C}}$ 52.5 (–OCH$_3$) confirmed the existence of a methoxy group.
|
||||
|
||||
To precisely elucidate the structure, the NMR data of **3** were compared with those of the known compound 5-$O$-galloyl-(–)-shikimic acid [Reference]. The spectra showed high similarity in both the galloyl moiety and the shikimic acid skeleton, with chemical shifts and coupling patterns matching well with the reference. Notably, the H-5 proton signal of **3** at $\\delta_{\\text{H}}$ 5.75 (1H, br s) was significantly downfield shifted, consistent with the deshielding effect observed in 5-$O$-galloyl-(–)-shikimic acid. This strongly supported the attachment of the galloyl group to the C-5 hydroxyl of the shikimic acid unit via an ester linkage. The primary structural difference between **3** and 5-$O$-galloyl-(–)-shikimic acid was located at C-7. In the HMBC spectrum (Fig. 2), a distinct correlation between the methoxy protons at $\\delta_{\\text{H}}$ 3.75 and the C-7 carbonyl carbon of the shikimate core at $\\delta_{\\text{C}}$ 168.3 confirmed that the C-7 carboxyl group was methylated to form a methyl ester.
|
||||
|
||||
The NOESY spectrum (Fig. 2) exhibited correlations of H-4 ($\\delta_{\\text{H}}$ 4.00) with H-2a ($\\delta_{\\text{H}}$ 2.32) and H-5 ($\\delta_{\\text{H}}$ 5.75), as well as H-3 ($\\delta_{\\text{H}}$ 4.12) with H-2b ($\\delta_{\\text{H}}$ 2.79). These correlations indicated that H-3 is <i>cis</i>-oriented relative to the 4-OH and 5-OH groups. The absolute configuration was further determined as (3$R$, 4$S$, 5$R$) by calculated electronic circular dichroism (ECD) analysis (Fig. 4).
|
||||
|
||||
Based on the comprehensive spectroscopic analysis and comparison with 5-$O$-galloyl-(–)-shikimic acid, the structure of **3** was identified as methyl 5-$O$-galloyl-(–)-shikimate.
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getMarkdown, parseMarkdownToStructure, processTokens } from '../src'
|
||||
|
||||
function walkNodes(nodes: any[], visit: (node: any) => void) {
|
||||
for (const node of nodes) {
|
||||
if (!node)
|
||||
continue
|
||||
visit(node)
|
||||
if (Array.isArray(node.children))
|
||||
walkNodes(node.children, visit)
|
||||
if (node.type === 'list' && Array.isArray(node.items))
|
||||
walkNodes(node.items, visit)
|
||||
if (node.type === 'table') {
|
||||
if (node.header)
|
||||
walkNodes([node.header], visit)
|
||||
if (Array.isArray(node.rows))
|
||||
walkNodes(node.rows, visit)
|
||||
}
|
||||
if (node.type === 'table_row' && Array.isArray(node.cells))
|
||||
walkNodes(node.cells, visit)
|
||||
}
|
||||
}
|
||||
|
||||
describe('hTML allowlist (方案 A)', () => {
|
||||
it('renders unknown tag <question> as literal text by default', () => {
|
||||
const md = getMarkdown()
|
||||
const nodes = parseMarkdownToStructure('Hello <question> world', md, { final: true })
|
||||
const para = nodes[0] as any
|
||||
expect(para.type).toBe('paragraph')
|
||||
const text = (para.children ?? [])
|
||||
.filter((c: any) => c.type === 'text')
|
||||
.map((c: any) => c.content)
|
||||
.join('')
|
||||
expect(text).toBe('Hello <question> world')
|
||||
expect((para.children ?? []).some((c: any) => c.type === 'html_inline')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps standard HTML tags as html_inline/html_block', () => {
|
||||
const md = getMarkdown()
|
||||
const nodes = parseMarkdownToStructure('Hello <span>ok</span>', md, { final: true })
|
||||
const para = nodes[0] as any
|
||||
expect(para.type).toBe('paragraph')
|
||||
expect((para.children ?? []).some((c: any) => c.type === 'html_inline' && c.tag === 'span')).toBe(true)
|
||||
|
||||
const block = parseMarkdownToStructure('<div>ok</div>', md, { final: true })
|
||||
expect(block[0]?.type).toBe('html_block')
|
||||
})
|
||||
|
||||
it('treats tags in customHtmlTags as custom nodes', () => {
|
||||
const md = getMarkdown(undefined, { customHtmlTags: ['thinking'] })
|
||||
const nodes = parseMarkdownToStructure('before <thinking>hi</thinking> after', md, {
|
||||
customHtmlTags: ['thinking'],
|
||||
final: true,
|
||||
})
|
||||
const para = nodes[0] as any
|
||||
expect(para.type).toBe('paragraph')
|
||||
expect((para.children ?? []).some((c: any) => c.type === 'thinking')).toBe(true)
|
||||
})
|
||||
|
||||
it('processTokens also applies the allowlist', () => {
|
||||
const md = getMarkdown()
|
||||
const tokens = md.parse('Hello <question> world', {})
|
||||
const nodes = processTokens(tokens as any, { final: true } as any) as any[]
|
||||
const htmlInlineQuestion: any[] = []
|
||||
walkNodes(nodes, (node) => {
|
||||
if (node.type === 'html_inline' && String(node.tag ?? '').toLowerCase() === 'question')
|
||||
htmlInlineQuestion.push(node)
|
||||
})
|
||||
expect(htmlInlineQuestion.length).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,361 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getMarkdown, parseMarkdownToStructure } from '../src'
|
||||
|
||||
function firstHtml(nodes: any[]) {
|
||||
return nodes.find(n => n.type === 'html_block')
|
||||
}
|
||||
|
||||
describe('html_block parser', () => {
|
||||
it('marks unclosed <div> as loading and appends closing tag', () => {
|
||||
const md = getMarkdown()
|
||||
const nodes = parseMarkdownToStructure('<div>hello', md)
|
||||
const html = firstHtml(nodes) as any
|
||||
expect(html).toBeDefined()
|
||||
expect(html.type).toBe('html_block')
|
||||
expect(html.tag).toBe('div')
|
||||
expect(html.loading).toBe(true)
|
||||
expect(html.content).toContain('</div>')
|
||||
})
|
||||
|
||||
it('handles uppercase closing tag case-insensitively', () => {
|
||||
const md = getMarkdown()
|
||||
const nodes = parseMarkdownToStructure('<DIV>Hi</DIV>', md)
|
||||
const html = firstHtml(nodes) as any
|
||||
expect(html.loading).toBe(false)
|
||||
expect(html.tag).toBe('div')
|
||||
})
|
||||
|
||||
it('treats void tags as closed (no loading, no fake close)', () => {
|
||||
const md = getMarkdown()
|
||||
const nodes = parseMarkdownToStructure('<br>', md)
|
||||
const html = firstHtml(nodes) as any
|
||||
expect(html.loading).toBe(false)
|
||||
expect(String(html.content)).not.toContain('</br>')
|
||||
})
|
||||
|
||||
it('treats self-closing first tag as closed', () => {
|
||||
const md = getMarkdown()
|
||||
const nodes = parseMarkdownToStructure('<img src="/x.png" />', md)
|
||||
const html = firstHtml(nodes) as any
|
||||
expect(html.loading).toBe(false)
|
||||
expect(String(html.content)).toContain('<img')
|
||||
expect(String(html.content)).not.toContain('</img>')
|
||||
})
|
||||
|
||||
it('does not attempt to close comments/doctypes/PIs', () => {
|
||||
const md = getMarkdown()
|
||||
const a = firstHtml(parseMarkdownToStructure('<!-- comment -->', md)) as any
|
||||
const b = firstHtml(parseMarkdownToStructure('<!DOCTYPE html>', md)) as any
|
||||
const c = firstHtml(parseMarkdownToStructure('<?xml version="1.0"?>', md)) as any
|
||||
expect(a?.loading).toBe(false)
|
||||
expect(b?.loading).toBe(false)
|
||||
expect(c?.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('does not let HTML comments absorb following Markdown blocks', () => {
|
||||
const markdown = `<!-- 基础图片 -->
|
||||

|
||||
|
||||
## 标题
|
||||
|
||||
---
|
||||
|
||||
正文`
|
||||
|
||||
const md = getMarkdown('html-comment-followed-by-markdown')
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
|
||||
|
||||
expect(nodes.map(node => node.type)).toEqual([
|
||||
'html_block',
|
||||
'paragraph',
|
||||
'heading',
|
||||
'thematic_break',
|
||||
'paragraph',
|
||||
])
|
||||
expect(String(nodes[0]?.raw ?? '').trim()).toBe('<!-- 基础图片 -->')
|
||||
expect(nodes[1]?.children?.[0]?.type).toBe('image')
|
||||
expect(nodes[1]?.children?.[0]?.src).toBe('https://example.com/a.png')
|
||||
expect(nodes[2]?.text).toBe('标题')
|
||||
expect(nodes[4]?.children?.[0]?.content).toBe('正文')
|
||||
})
|
||||
|
||||
it('keeps outer same-tag html blocks loading until the matching outer close arrives', () => {
|
||||
const md = getMarkdown()
|
||||
const nodes = parseMarkdownToStructure('<div>outer<div>inner</div>rest', md, { final: false })
|
||||
const html = firstHtml(nodes) as any
|
||||
expect(html).toBeDefined()
|
||||
expect(html.type).toBe('html_block')
|
||||
expect(html.tag).toBe('div')
|
||||
expect(html.loading).toBe(true)
|
||||
})
|
||||
|
||||
it('combines details blocks into a single container node with parsed children', () => {
|
||||
const markdown = `<details open>
|
||||
<summary>Thinking...</summary>
|
||||
这里是详情
|
||||
|
||||
首先。。。。
|
||||
</details>
|
||||
`
|
||||
|
||||
const md = getMarkdown('html-block-details-container')
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
|
||||
const html = firstHtml(nodes) as any
|
||||
const summary = html?.children?.[0]
|
||||
|
||||
expect(nodes).toHaveLength(1)
|
||||
expect(html?.tag).toBe('details')
|
||||
expect(html?.attrs).toEqual([['open', '']])
|
||||
expect(String(html?.raw ?? '')).toBe(markdown)
|
||||
expect(String(html?.content ?? '')).toContain('<p>首先。。。。</p>')
|
||||
expect(summary?.tag).toBe('summary')
|
||||
expect(html?.children?.[1]?.type).toBe('paragraph')
|
||||
expect(html?.children?.[2]?.type).toBe('paragraph')
|
||||
})
|
||||
|
||||
it('keeps nested details grouped under the outer details container', () => {
|
||||
const markdown = `<details open>
|
||||
<summary>outer</summary>
|
||||
outer start
|
||||
|
||||
<details>
|
||||
<summary>inner</summary>
|
||||
|
||||
inner body
|
||||
</details>
|
||||
|
||||
after inner
|
||||
</details>`
|
||||
|
||||
for (const final of [false, true]) {
|
||||
const md = getMarkdown(`html-block-details-nested-container-${final}`)
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final }) as any[]
|
||||
const outer = firstHtml(nodes) as any
|
||||
const outerSummary = outer?.children?.find((child: any) => child?.type === 'html_block' && child?.tag === 'summary')
|
||||
const inner = outer?.children?.find((child: any) => child?.type === 'html_block' && child?.tag === 'details')
|
||||
|
||||
expect(nodes).toHaveLength(1)
|
||||
expect(outer?.tag).toBe('details')
|
||||
expect(String(outer?.raw ?? '')).toBe(markdown)
|
||||
expect(outerSummary).toBeDefined()
|
||||
expect(inner).toBeDefined()
|
||||
expect(inner?.children?.[0]?.tag).toBe('summary')
|
||||
expect(String(inner?.content ?? '')).toContain('<p>inner body</p>')
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps complex markdown blocks inside details and preserves the closing newline', () => {
|
||||
const markdown = `<details open>
|
||||
<summary>sum</summary>
|
||||
|
||||
- a
|
||||
- b
|
||||
|
||||
> q
|
||||
|
||||
\`\`\`js
|
||||
console.log(1)
|
||||
\`\`\`
|
||||
|
||||
tail
|
||||
</details>
|
||||
`
|
||||
|
||||
const md = getMarkdown('html-block-details-complex')
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
|
||||
const html = firstHtml(nodes) as any
|
||||
|
||||
expect(nodes).toHaveLength(1)
|
||||
expect(html?.tag).toBe('details')
|
||||
expect(String(html?.raw ?? '')).toBe(markdown)
|
||||
expect(String(html?.content ?? '')).toContain('<ul>')
|
||||
expect(String(html?.content ?? '')).toContain('<blockquote>')
|
||||
expect(String(html?.content ?? '')).toContain('data-lang="js"')
|
||||
expect(String(html?.content ?? '')).toContain('<p>tail</p>')
|
||||
expect(html?.children?.map((child: any) => child?.type === 'html_block' ? `${child?.type}:${child?.tag}` : child?.type))
|
||||
.toEqual(['html_block:summary', 'list', 'blockquote', 'code_block', 'paragraph'])
|
||||
})
|
||||
|
||||
it('parses markdown inside summary into summary children', () => {
|
||||
const markdown = `<details open>
|
||||
<summary>**sum** _x_</summary>
|
||||
|
||||
body
|
||||
</details>
|
||||
`
|
||||
|
||||
const md = getMarkdown('html-block-details-summary-markdown')
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
|
||||
const html = firstHtml(nodes) as any
|
||||
const summary = html?.children?.[0]
|
||||
const summaryParagraph = summary?.children?.[0]
|
||||
|
||||
expect(nodes).toHaveLength(1)
|
||||
expect(summary?.tag).toBe('summary')
|
||||
expect(html?.attrs).toEqual([['open', '']])
|
||||
expect(summaryParagraph?.children?.[0]?.type).toBe('strong')
|
||||
expect(summaryParagraph?.children?.[0]?.children?.[0]?.content).toBe('sum')
|
||||
expect(summaryParagraph?.children?.[2]?.type).toBe('emphasis')
|
||||
expect(summaryParagraph?.children?.[2]?.children?.[0]?.content).toBe('x')
|
||||
expect(html?.children?.[1]?.children?.[0]?.content).toBe('body')
|
||||
})
|
||||
|
||||
it('keeps opener content before and after summary as structured children', () => {
|
||||
const markdown = `<details open>
|
||||
lead
|
||||
<summary>sum</summary>
|
||||
text
|
||||
<div>inner</div>
|
||||
|
||||
para
|
||||
</details>
|
||||
`
|
||||
|
||||
const md = getMarkdown('html-block-details-opener-mixed')
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
|
||||
const html = firstHtml(nodes) as any
|
||||
|
||||
expect(nodes).toHaveLength(1)
|
||||
expect(html?.attrs).toEqual([['open', '']])
|
||||
expect(html?.children?.map((child: any) => child?.type === 'html_block' ? `${child?.type}:${child?.tag}` : child?.type))
|
||||
.toEqual(['paragraph', 'html_block:summary', 'paragraph', 'html_block:div', 'paragraph'])
|
||||
expect(html?.children?.[0]?.children?.[0]?.content).toBe('lead')
|
||||
expect(html?.children?.[1]?.children?.[0]?.children?.[0]?.content).toBe('sum')
|
||||
expect(html?.children?.[2]?.children?.[0]?.content).toBe('text')
|
||||
expect(html?.children?.[3]?.tag).toBe('div')
|
||||
expect(html?.children?.[4]?.children?.[0]?.content).toBe('para')
|
||||
})
|
||||
|
||||
it('does not absorb content after a self-contained details block', () => {
|
||||
const markdown = `<details style="color:gray;background-color: #f8f8f8;padding: 8px;border-radius: 4px;" open> <summary> Thinking... </summary> 好的,我现在需要处理用户的问题:"你是谁"。首先,我要按照用户设定的规则来回答。用户要求先检查提供的上下文(context)中的信息,如果在里面找不到答案,再用 \n</details> \n\n我是通义千问,阿里巴巴集团旗下的超大规模语言模型。我能够帮助你回答问题、创作文字(如写故事、写公文、写邮件、写剧本等)、进行逻辑推理、编程等任务,并支持多语言交流。\n`
|
||||
|
||||
const md = getMarkdown('html-block-details-self-contained')
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
|
||||
|
||||
expect(nodes).toHaveLength(2)
|
||||
expect(nodes[0]?.type).toBe('html_block')
|
||||
expect(nodes[0]?.tag).toBe('details')
|
||||
expect(nodes[1]?.type).toBe('paragraph')
|
||||
})
|
||||
|
||||
it('self-contained details with no trailing content produces a single node', () => {
|
||||
const markdown = `<details open> <summary>S</summary> body \n</details>\n`
|
||||
|
||||
const md = getMarkdown('html-block-self-contained-no-trailing')
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
|
||||
|
||||
expect(nodes).toHaveLength(1)
|
||||
expect(nodes[0]?.type).toBe('html_block')
|
||||
expect(nodes[0]?.tag).toBe('details')
|
||||
expect((nodes[0] as any)?.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('self-contained details followed by a heading keeps them separate', () => {
|
||||
const markdown = `<details open> <summary>S</summary> body \n</details>\n\n# Heading\n`
|
||||
|
||||
const md = getMarkdown('html-block-self-contained-heading')
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
|
||||
|
||||
expect(nodes).toHaveLength(2)
|
||||
expect(nodes[0]?.type).toBe('html_block')
|
||||
expect(nodes[0]?.tag).toBe('details')
|
||||
expect(nodes[1]?.type).toBe('heading')
|
||||
})
|
||||
|
||||
it('two consecutive self-contained details blocks stay separate', () => {
|
||||
const markdown = `<details open> <summary>A</summary> aaa \n</details>\n\n<details> <summary>B</summary> bbb \n</details>\n`
|
||||
|
||||
const md = getMarkdown('html-block-self-contained-consecutive')
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
|
||||
|
||||
expect(nodes).toHaveLength(2)
|
||||
expect(nodes[0]?.type).toBe('html_block')
|
||||
expect(nodes[0]?.tag).toBe('details')
|
||||
expect(nodes[1]?.type).toBe('html_block')
|
||||
expect(nodes[1]?.tag).toBe('details')
|
||||
})
|
||||
|
||||
it('self-contained details followed by a multi-line details block stay separate', () => {
|
||||
const markdown = `<details open> <summary>A</summary> aaa \n</details>\n\n<details>\n<summary>B</summary>\n\nbbb\n</details>\n`
|
||||
|
||||
const md = getMarkdown('html-block-self-contained-then-multiline')
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
|
||||
|
||||
expect(nodes).toHaveLength(2)
|
||||
expect(nodes[0]?.type).toBe('html_block')
|
||||
expect(nodes[0]?.tag).toBe('details')
|
||||
expect(nodes[1]?.type).toBe('html_block')
|
||||
expect(nodes[1]?.tag).toBe('details')
|
||||
expect(String(nodes[1]?.content ?? '')).toContain('<p>bbb</p>')
|
||||
})
|
||||
|
||||
it('self-contained details followed by a code block stay separate', () => {
|
||||
const markdown = '<details open> <summary>S</summary> body \n</details>\n\n```js\nconsole.log(1)\n```\n'
|
||||
|
||||
const md = getMarkdown('html-block-self-contained-code')
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
|
||||
|
||||
expect(nodes).toHaveLength(2)
|
||||
expect(nodes[0]?.type).toBe('html_block')
|
||||
expect(nodes[0]?.tag).toBe('details')
|
||||
expect(nodes[1]?.type).toBe('code_block')
|
||||
})
|
||||
|
||||
it('self-contained details in streaming mode does not absorb following content', () => {
|
||||
const markdown = `<details open> <summary>S</summary> body \n</details> \n\n我是通义千问\n`
|
||||
|
||||
for (const final of [false, true]) {
|
||||
const md = getMarkdown(`html-block-self-contained-stream-${final}`)
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final }) as any[]
|
||||
|
||||
expect(nodes).toHaveLength(2)
|
||||
expect(nodes[0]?.type).toBe('html_block')
|
||||
expect(nodes[0]?.tag).toBe('details')
|
||||
expect((nodes[0] as any)?.loading).toBe(false)
|
||||
expect(nodes[1]?.type).toBe('paragraph')
|
||||
}
|
||||
})
|
||||
|
||||
it('self-contained details preserves attributes correctly', () => {
|
||||
const markdown = `<details style="background:#f8f8f8" open> <summary>S</summary> body \n</details>\n\npara\n`
|
||||
|
||||
const md = getMarkdown('html-block-self-contained-attrs')
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
|
||||
|
||||
expect(nodes).toHaveLength(2)
|
||||
const details = nodes[0] as any
|
||||
expect(details?.tag).toBe('details')
|
||||
expect(details?.attrs).toEqual([['style', 'background:#f8f8f8'], ['open', '']])
|
||||
expect(details?.children?.find((c: any) => c?.tag === 'summary')).toBeDefined()
|
||||
expect(nodes[1]?.type).toBe('paragraph')
|
||||
})
|
||||
|
||||
it('self-contained details preserves summary children and body text', () => {
|
||||
const markdown = `<details open> <summary> Thinking... </summary> 好的 \n</details> \n\n我是通义千问\n`
|
||||
|
||||
const md = getMarkdown('html-block-self-contained-children')
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
|
||||
|
||||
expect(nodes).toHaveLength(2)
|
||||
const details = nodes[0] as any
|
||||
expect(details?.tag).toBe('details')
|
||||
expect(details?.loading).toBe(false)
|
||||
const summary = details?.children?.find((c: any) => c?.tag === 'summary')
|
||||
expect(summary).toBeDefined()
|
||||
expect(nodes[1]?.type).toBe('paragraph')
|
||||
})
|
||||
|
||||
it('self-contained details followed by multiple paragraphs stays separate from all', () => {
|
||||
const markdown = `<details open> <summary>S</summary> body \n</details> \n\npara1\n\npara2\n`
|
||||
|
||||
const md = getMarkdown('html-block-self-contained-multi-para')
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
|
||||
|
||||
expect(nodes.length).toBeGreaterThanOrEqual(2)
|
||||
expect(nodes[0]?.type).toBe('html_block')
|
||||
expect(nodes[0]?.tag).toBe('details')
|
||||
for (let k = 1; k < nodes.length; k++) {
|
||||
expect(nodes[k]?.type).not.toBe('html_block')
|
||||
}
|
||||
})
|
||||
})
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getMarkdown, parseMarkdownToStructure } from '../src'
|
||||
|
||||
describe('html inline malformed custom-tag regression', () => {
|
||||
it('keeps malformed "<robot=...>" as literal text instead of dropping children', () => {
|
||||
const md = getMarkdown()
|
||||
const markdown = '<robot=翻转检测>尘盒搞定!'
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
const para = nodes[0] as any
|
||||
|
||||
expect(para.type).toBe('paragraph')
|
||||
expect(Array.isArray(para.children)).toBe(true)
|
||||
expect(para.children.length).toBeGreaterThan(0)
|
||||
|
||||
const text = (para.children ?? [])
|
||||
.filter((c: any) => c.type === 'text')
|
||||
.map((c: any) => String(c.content ?? ''))
|
||||
.join('')
|
||||
|
||||
expect(text).toContain('<robot=翻转检测>')
|
||||
expect(text).toContain('尘盒搞定!')
|
||||
})
|
||||
|
||||
it('keeps valid custom-tag attributes working (e.g. <robot xxx="sa">)', () => {
|
||||
const md = getMarkdown('robot-attrs', { customHtmlTags: ['robot'] })
|
||||
const markdown = '<robot xxx="sa">维护中</robot>'
|
||||
const nodes = parseMarkdownToStructure(markdown, md, {
|
||||
customHtmlTags: ['robot'],
|
||||
final: true,
|
||||
})
|
||||
const para = nodes[0] as any
|
||||
const robot = (para.children ?? []).find((c: any) => c.type === 'robot')
|
||||
|
||||
expect(robot).toBeDefined()
|
||||
expect(robot.attrs).toContainEqual(['xxx', 'sa'])
|
||||
expect(robot.children?.[0]?.type).toBe('text')
|
||||
expect(String(robot.children?.[0]?.content ?? '')).toContain('维护中')
|
||||
})
|
||||
|
||||
it('does not affect normal html_inline parsing for standard tags', () => {
|
||||
const md = getMarkdown()
|
||||
const markdown = 'before <span data-x="1">ok</span> after'
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
const para = nodes[0] as any
|
||||
const spanNode = (para.children ?? []).find((c: any) => c.type === 'html_inline' && c.tag === 'span')
|
||||
|
||||
expect(spanNode).toBeDefined()
|
||||
expect(spanNode.children?.[0]?.type).toBe('text')
|
||||
expect(spanNode.children?.[0]?.content).toBe('ok')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getMarkdown, parseMarkdownToStructure } from '../src'
|
||||
|
||||
// Focus tests on the core plugin behavior added in fixHtmlInline
|
||||
|
||||
describe('fixHtmlInline core plugin', () => {
|
||||
it('expands single-token inline <a> with inner text', () => {
|
||||
const md = getMarkdown()
|
||||
const markdown = `<a href="https://example.com">Here</a>`
|
||||
const nodes = parseMarkdownToStructure(markdown, md)
|
||||
// nodes[0] should be paragraph with a link child
|
||||
const para = nodes[0] as any
|
||||
expect(para.type).toBe('paragraph')
|
||||
const link = para.children.find((c: any) => c.type === 'link')
|
||||
expect(link).toBeDefined()
|
||||
expect(link.href).toBe('https://example.com')
|
||||
expect(link.children?.[0]?.content).toBe('Here')
|
||||
})
|
||||
|
||||
it('handles attribute values containing ">" inside quotes when splitting open tag', () => {
|
||||
const md = getMarkdown()
|
||||
const markdown = `<a href="https://example.com?q=a>b&x=1">Here</a>`
|
||||
const nodes = parseMarkdownToStructure(markdown, md)
|
||||
const para = nodes[0] as any
|
||||
const link = para.children.find((c: any) => c.type === 'link')
|
||||
expect(link).toBeDefined()
|
||||
expect(link.href).toBe('https://example.com?q=a>b&x=1')
|
||||
expect(link.children?.[0]?.content).toBe('Here')
|
||||
})
|
||||
|
||||
it('treats void/self-closing tag inline as single html chunk (fallback to inline_code)', () => {
|
||||
const md = getMarkdown()
|
||||
const markdown = `before <img src="/x.png" alt="pic" /> after`
|
||||
const nodes = parseMarkdownToStructure(markdown, md)
|
||||
const para = nodes[0] as any
|
||||
expect(para.type).toBe('paragraph')
|
||||
const htmlInline = para.children.find((c: any) => c.type === 'html_inline')
|
||||
expect(htmlInline).toBeDefined()
|
||||
expect(htmlInline.content).toContain('<img')
|
||||
// Ensure no weird closing tag was synthesized in output
|
||||
expect(String(htmlInline.content)).not.toContain('</img>')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getMarkdown, parseMarkdownToStructure } from '../src'
|
||||
|
||||
describe('html_inline parsing', () => {
|
||||
it('parses <a> as link node with href and inner text', () => {
|
||||
const md = getMarkdown()
|
||||
const markdown = `This is a <a href="https://example.com">Example</a> link.`
|
||||
const nodes = parseMarkdownToStructure(markdown, md)
|
||||
expect(nodes.length).toBeGreaterThan(0)
|
||||
const para = nodes[0]
|
||||
expect(para.type).toBe('paragraph')
|
||||
const linkChild = (para as any).children.find((c: any) => c.type === 'link')
|
||||
expect(linkChild).toBeDefined()
|
||||
expect(linkChild.href).toBe('https://example.com')
|
||||
expect(linkChild.children[0].content).toBe('Example')
|
||||
})
|
||||
|
||||
it('preserves custom <a> attributes on link node attrs', () => {
|
||||
const md = getMarkdown()
|
||||
const markdown = `<a href="https://example.com" target="_self" rel="nofollow" data-track="cta">Example</a>`
|
||||
const nodes = parseMarkdownToStructure(markdown, md)
|
||||
const para = nodes[0] as any
|
||||
const linkChild = para.children.find((c: any) => c.type === 'link')
|
||||
|
||||
expect(linkChild).toBeDefined()
|
||||
expect(linkChild.href).toBe('https://example.com')
|
||||
expect(linkChild.attrs).toEqual(expect.arrayContaining([
|
||||
['href', 'https://example.com'],
|
||||
['target', '_self'],
|
||||
['rel', 'nofollow'],
|
||||
['data-track', 'cta'],
|
||||
]))
|
||||
})
|
||||
|
||||
it('creates html_inline nodes for generic inline html like <span>', () => {
|
||||
const md = getMarkdown()
|
||||
const markdown = `Before <span>inner span</span> After`
|
||||
const nodes = parseMarkdownToStructure(markdown, md)
|
||||
const para = nodes[0]
|
||||
const spanNode = (para as any).children.find((c: any) => c.type === 'html_inline')
|
||||
expect(spanNode).toBeDefined()
|
||||
expect(spanNode.content).toContain('<span')
|
||||
expect(spanNode.content).toContain('</span>')
|
||||
expect(spanNode.children?.[0]?.content).toBe('inner span')
|
||||
})
|
||||
|
||||
it('handles attribute values that include ">" characters', () => {
|
||||
const md = getMarkdown()
|
||||
const markdown = `Value <a href="https://example.com?q=a>b&x=1">Here</a> end`
|
||||
const nodes = parseMarkdownToStructure(markdown, md)
|
||||
const para = nodes[0]
|
||||
const linkChild = (para as any).children.find((c: any) => c.type === 'link')
|
||||
expect(linkChild).toBeDefined()
|
||||
// href extraction should capture full quoted value including '>'
|
||||
expect(linkChild.href).toBe('https://example.com?q=a>b&x=1')
|
||||
})
|
||||
|
||||
it('handles missing closing tag gracefully (unclosed <a>)', () => {
|
||||
const md = getMarkdown()
|
||||
const markdown = `Start <a href="https://example.com">Unclosed text`
|
||||
const nodes = parseMarkdownToStructure(markdown, md)
|
||||
// Should not throw; ensure raw input is preserved in some node
|
||||
const serialized = JSON.stringify(nodes)
|
||||
// JSON will escape quotes, so check for a less strict substring and for href URL
|
||||
expect(serialized).toContain('<a href')
|
||||
expect(serialized).toContain('https://example.com')
|
||||
expect(serialized).toContain('Unclosed text')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getMarkdown, parseMarkdownToStructure } from '../src'
|
||||
|
||||
describe('parser inline code multiple spans', () => {
|
||||
it('parses multiple inline code spans and emphasis correctly', () => {
|
||||
const md = getMarkdown()
|
||||
const markdown = '1. **整合冗余条款:** 将原`1-(5)`、`3-(3)`、`3-(4)`中关于“缴纳费用”和“告知法律状态变化”的核心义务合并,统一放入`1-(4)`“法律状态维护与通知义务”中,使其逻辑更集中,避免了在不同部分重复提及相似义务。'
|
||||
|
||||
const nodes = parseMarkdownToStructure(markdown, md)
|
||||
|
||||
const serialized = JSON.stringify(nodes)
|
||||
// Ensure we produced a strong node
|
||||
expect(serialized).toContain('"type":"strong"')
|
||||
|
||||
// Also ensure code spans exist and expected snippets appear
|
||||
expect(serialized).toContain('1-(5)')
|
||||
expect(serialized).toContain('3-(3)')
|
||||
expect(serialized).toContain('3-(4)')
|
||||
expect(serialized).toContain('1-(4)')
|
||||
})
|
||||
|
||||
it('handles mid-state with trailing opening backtick in list item', () => {
|
||||
const md = getMarkdown()
|
||||
// mid-state: trailing opening backtick after a comma
|
||||
const markdownMid = '1. **整合冗余条款:** 将原`1-(5)`、`'
|
||||
const nodesMid = parseMarkdownToStructure(markdownMid, md)
|
||||
const serializedMid = JSON.stringify(nodesMid)
|
||||
// debug: dump nodes for diagnostic
|
||||
|
||||
console.log('DEBUG_NODES_MID_FULL:', JSON.stringify(nodesMid, null, 2))
|
||||
// debug output removed
|
||||
|
||||
// Should contain a strong node for the leading bold and an inline_code mid-state
|
||||
expect(serializedMid).toContain('"type":"strong"')
|
||||
// mid-state inline code: there may be an inline_code node with empty code or raw ending in backtick
|
||||
expect(serializedMid.includes('inline_code') || serializedMid.includes('`')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('handles mid-state with trailing opening backtick in list item mid-state 1', () => {
|
||||
const md = getMarkdown()
|
||||
// mid-state: trailing opening backtick after a comma
|
||||
const markdownMid = '1. **整合冗余条款:** 将原`1-(5)`、`3'
|
||||
const nodesMid = parseMarkdownToStructure(markdownMid, md)
|
||||
const children = nodesMid[0].items[0].children[0].children
|
||||
// Should contain a strong node for the leading bold and an inline_code mid-state
|
||||
expect(children[0].type).toBe('strong')
|
||||
expect(children[0].raw).toBe('**整合冗余条款:**')
|
||||
expect(children[1].type).toBe('text')
|
||||
expect(children[1].content).toBe(' 将原')
|
||||
expect(children[2].type).toBe('inline_code')
|
||||
expect(children[2].code).toBe('1-(5)')
|
||||
// The punctuation between code spans may be parsed as text or as a tiny
|
||||
// inline_code node in mid-state scenarios depending on token boundaries.
|
||||
// Accept either a text node or an inline_code node for the punctuation
|
||||
expect(['text', 'inline_code']).toContain(children[3]?.type)
|
||||
// Ensure at least one child contains the punctuation '、' either as content or as inline code
|
||||
const hasPunctuation = children.some((c: any) => (c.content === '、') || (c.code === '、'))
|
||||
expect(hasPunctuation).toBeTruthy()
|
||||
})
|
||||
|
||||
it('handles mid-state with trailing opening backtick in list item mid-state 2', () => {
|
||||
const md = getMarkdown()
|
||||
// mid-state: trailing opening backtick after a comma
|
||||
const markdownMid = '1. **整合冗余条款:** 将原`1-(5)`、`3-(3)`、`3-(4)`中关于“缴纳费用”和“告知法律状态变化”的核心义务合并,统一放入`1-'
|
||||
const nodesMid = parseMarkdownToStructure(markdownMid, md)
|
||||
const children = nodesMid[0].items[0].children[0].children
|
||||
// Should contain a strong node for the leading bold and an inline_code mid-state
|
||||
expect(children[0].type).toBe('strong')
|
||||
expect(children[0].raw).toBe('**整合冗余条款:**')
|
||||
expect(children[1].type).toBe('text')
|
||||
expect(children[1].content).toBe(' 将原')
|
||||
expect(children[2].type).toBe('inline_code')
|
||||
expect(children[2].code).toBe('1-(5)')
|
||||
expect(children[3].type).toBe('text')
|
||||
expect(children[3].content).toBe('、')
|
||||
expect(children[4].type).toBe('inline_code')
|
||||
expect(children[4].code).toBe('3-(3)')
|
||||
expect(children[5].type).toBe('text')
|
||||
expect(children[5].content).toBe('、')
|
||||
expect(children[6].type).toBe('inline_code')
|
||||
expect(children[6].code).toBe('3-(4)')
|
||||
expect(children[7].type).toBe('text')
|
||||
expect(children[7].content).toBe('中关于“缴纳费用”和“告知法律状态变化”的核心义务合并,统一放入')
|
||||
expect(children[8].type).toBe('inline_code')
|
||||
expect(children[8].code).toBe('1-')
|
||||
})
|
||||
|
||||
it('handles mid-state with trailing opening backtick in list item mid-state 3', () => {
|
||||
const md = getMarkdown()
|
||||
// mid-state: trailing opening backtick after a comma
|
||||
const markdownMid = '1. **整合冗余条款:** 将原\`1-(5)\`、\`3-(3)\`、\`3-(4)\`中关于“缴纳费用”和“告知法律状态变化”的核心义务合并,统一放入\`1-(4)\`“法律状态维护与通知义务”中,使其逻辑更集中,避免了在不同部分重复提及相似义务。'
|
||||
const nodesMid = parseMarkdownToStructure(markdownMid, md)
|
||||
const children = nodesMid[0].items[0].children[0].children
|
||||
// Should contain a strong node for the leading bold and an inline_code mid-state
|
||||
expect(children[0].type).toBe('strong')
|
||||
expect(children[0].raw).toBe('**整合冗余条款:**')
|
||||
expect(children[1].type).toBe('text')
|
||||
expect(children[1].content).toBe(' 将原')
|
||||
expect(children[2].type).toBe('inline_code')
|
||||
expect(children[2].code).toBe('1-(5)')
|
||||
expect(children[3].type).toBe('text')
|
||||
expect(children[3].content).toBe('、')
|
||||
expect(children[4].type).toBe('inline_code')
|
||||
expect(children[4].code).toBe('3-(3)')
|
||||
expect(children[5].type).toBe('text')
|
||||
expect(children[5].content).toBe('、')
|
||||
expect(children[6].type).toBe('inline_code')
|
||||
expect(children[6].code).toBe('3-(4)')
|
||||
expect(children[7].type).toBe('text')
|
||||
expect(children[7].content).toBe('中关于“缴纳费用”和“告知法律状态变化”的核心义务合并,统一放入')
|
||||
expect(children[8].type).toBe('inline_code')
|
||||
expect(children[8].code).toBe('1-(4)')
|
||||
expect(children[9].type).toBe('text')
|
||||
expect(children[9].content).toBe('“法律状态维护与通知义务”中,使其逻辑更集中,避免了在不同部分重复提及相似义务。')
|
||||
})
|
||||
|
||||
it('handles mid-state with trailing opening backtick in list item mid-state 4', () => {
|
||||
const md = getMarkdown()
|
||||
// mid-state: trailing opening backtick after a comma
|
||||
const markdownMid = '1. **整合冗余条款:** 将原 **hello world:** \`1-(5)\`、\`3-(3)\`、\`3-(4)\`中关于“缴纳费用”和“告知法律状态变化”的核心义务合并,统一放入\`1-(4)\`“法律状态维护与通知义务”中,使其逻辑更集中,避免了在不同部分重复提及相似义务。'
|
||||
const nodesMid = parseMarkdownToStructure(markdownMid, md)
|
||||
const children = nodesMid[0].items[0].children[0].children
|
||||
// Should contain a strong node for the leading bold and an inline_code mid-state
|
||||
const types = children.map((c: any) => c.type)
|
||||
expect(types).toEqual(['strong', 'text', 'strong', 'text', 'inline_code', 'text', 'inline_code', 'text', 'inline_code', 'text', 'inline_code', 'text'])
|
||||
})
|
||||
})
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getMarkdown, parseMarkdownToStructure } from '../src'
|
||||
|
||||
function parseParagraphChildren(markdown: string) {
|
||||
const md = getMarkdown('issue-334')
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
|
||||
const paragraph = nodes[0]
|
||||
expect(paragraph?.type).toBe('paragraph')
|
||||
return paragraph?.children ?? []
|
||||
}
|
||||
|
||||
function parseCustomComponentChildren(markdownContent: string) {
|
||||
const tags = ['thinking']
|
||||
const md = getMarkdown('issue-334-custom', { customHtmlTags: tags })
|
||||
const nodes = parseMarkdownToStructure(`<thinking>${markdownContent}</thinking>`, md, {
|
||||
customHtmlTags: tags,
|
||||
final: true,
|
||||
}) as any[]
|
||||
const paragraph = nodes[0]
|
||||
expect(paragraph?.type).toBe('paragraph')
|
||||
const thinking = (paragraph?.children ?? []).find((node: any) => node?.type === 'thinking')
|
||||
expect(thinking).toBeDefined()
|
||||
return thinking?.children ?? []
|
||||
}
|
||||
|
||||
function collectTypes(children: any[]) {
|
||||
return children.map((node: any) => node?.type)
|
||||
}
|
||||
|
||||
function collectByType(value: any, targetType: string): any[] {
|
||||
const out: any[] = []
|
||||
const walk = (node: any) => {
|
||||
if (!node)
|
||||
return
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(walk)
|
||||
return
|
||||
}
|
||||
if (node?.type === targetType)
|
||||
out.push(node)
|
||||
if (Array.isArray(node?.children))
|
||||
node.children.forEach(walk)
|
||||
if (Array.isArray(node?.items))
|
||||
node.items.forEach(walk)
|
||||
}
|
||||
walk(value)
|
||||
return out
|
||||
}
|
||||
|
||||
describe('issue #334 - inline formatting before math', () => {
|
||||
it('keeps strong, emphasis, and strikethrough nodes when inline math is on the same line', () => {
|
||||
const children = parseParagraphChildren('**粗体** 和 *斜体* 和 ~~删除线~~ 和 $E = mc^2$')
|
||||
expect(collectTypes(children)).toEqual([
|
||||
'strong',
|
||||
'text',
|
||||
'emphasis',
|
||||
'text',
|
||||
'strikethrough',
|
||||
'text',
|
||||
'math_inline',
|
||||
])
|
||||
|
||||
expect(children[0]?.children?.[0]?.content).toBe('粗体')
|
||||
expect(children[2]?.children?.[0]?.content).toBe('斜体')
|
||||
expect(children[4]?.children?.[0]?.content).toBe('删除线')
|
||||
expect(children[5]?.content).toBe(' 和 ')
|
||||
expect(children[6]?.content).toBe('E = mc^2')
|
||||
})
|
||||
|
||||
it('keeps underscore-based strong and emphasis nodes when inline math is on the same line', () => {
|
||||
const children = parseParagraphChildren('__粗体__ 和 _斜体_ 和 ~~删除线~~ 和 $E = mc^2$')
|
||||
|
||||
expect(collectTypes(children)).toEqual([
|
||||
'strong',
|
||||
'text',
|
||||
'emphasis',
|
||||
'text',
|
||||
'strikethrough',
|
||||
'text',
|
||||
'math_inline',
|
||||
])
|
||||
|
||||
expect(children[0]?.children?.[0]?.content).toBe('粗体')
|
||||
expect(children[2]?.children?.[0]?.content).toBe('斜体')
|
||||
expect(children[4]?.children?.[0]?.content).toBe('删除线')
|
||||
expect(children[6]?.content).toBe('E = mc^2')
|
||||
})
|
||||
|
||||
it('keeps nested triple-asterisk formatting before inline math', () => {
|
||||
const children = parseParagraphChildren('***粗斜体*** 和 ~~删除线~~ 和 $E = mc^2$')
|
||||
|
||||
const outer = children[0]
|
||||
const inner = outer?.children?.[0]
|
||||
expect(['strong', 'emphasis']).toContain(outer?.type)
|
||||
expect(['strong', 'emphasis']).toContain(inner?.type)
|
||||
expect(inner?.type).not.toBe(outer?.type)
|
||||
expect(inner?.children?.[0]?.content).toBe('粗斜体')
|
||||
|
||||
expect(children.slice(1).map((node: any) => node?.type)).toEqual([
|
||||
'text',
|
||||
'strikethrough',
|
||||
'text',
|
||||
'math_inline',
|
||||
])
|
||||
expect(children[2]?.children?.[0]?.content).toBe('删除线')
|
||||
expect(children[4]?.content).toBe('E = mc^2')
|
||||
})
|
||||
|
||||
it('keeps formatting order when inline math appears before later emphasis markers', () => {
|
||||
const children = parseParagraphChildren('$E = mc^2$ 和 **粗体** 和 *斜体* 和 ~~删除线~~')
|
||||
|
||||
expect(collectTypes(children)).toEqual([
|
||||
'math_inline',
|
||||
'text',
|
||||
'strong',
|
||||
'text',
|
||||
'emphasis',
|
||||
'text',
|
||||
'strikethrough',
|
||||
])
|
||||
|
||||
expect(children[0]?.content).toBe('E = mc^2')
|
||||
expect(children[2]?.children?.[0]?.content).toBe('粗体')
|
||||
expect(children[4]?.children?.[0]?.content).toBe('斜体')
|
||||
expect(children[6]?.children?.[0]?.content).toBe('删除线')
|
||||
})
|
||||
|
||||
it('does not turn escaped delimiters into formatting nodes before inline math', () => {
|
||||
const children = parseParagraphChildren('\\*\\*字面量\\*\\* 和 \\_\\_下划线\\_\\_ 和 $E = mc^2$')
|
||||
|
||||
expect(collectTypes(children)).toEqual([
|
||||
'text',
|
||||
'math_inline',
|
||||
])
|
||||
expect(children[0]?.content).toBe('**字面量** 和 __下划线__ 和 ')
|
||||
expect(children[1]?.content).toBe('E = mc^2')
|
||||
})
|
||||
|
||||
it('keeps escaped delimiters literal while still parsing later real formatting before inline math', () => {
|
||||
const children = parseParagraphChildren('\\*\\*字面量\\*\\* 和 **真实粗体** 和 $E = mc^2$')
|
||||
|
||||
expect(collectTypes(children)).toEqual([
|
||||
'text',
|
||||
'strong',
|
||||
'text',
|
||||
'math_inline',
|
||||
])
|
||||
expect(children[0]?.content).toBe('**字面量** 和 ')
|
||||
expect(children[1]?.children?.[0]?.content).toBe('真实粗体')
|
||||
expect(children[2]?.content).toBe(' 和 ')
|
||||
expect(children[3]?.content).toBe('E = mc^2')
|
||||
})
|
||||
|
||||
it('keeps math recoverable even when malformed nested formatting appears before it', () => {
|
||||
const children = parseParagraphChildren('**粗体 *未闭合 和 ~~删除线~~ 和 $E = mc^2$')
|
||||
|
||||
const mathNodes = collectByType(children, 'math_inline')
|
||||
expect(mathNodes).toHaveLength(1)
|
||||
expect(mathNodes[0]?.content).toBe('E = mc^2')
|
||||
|
||||
const serialized = JSON.stringify(children)
|
||||
expect(serialized).toContain('未闭合')
|
||||
expect(serialized).toContain('删除线')
|
||||
})
|
||||
})
|
||||
|
||||
describe('issue #334 - inline formatting inside custom components', () => {
|
||||
it('keeps strong, emphasis, strikethrough, and math inside custom component content', () => {
|
||||
const children = parseCustomComponentChildren('**粗体** 和 *斜体* 和 ~~删除线~~ 和 $E = mc^2$')
|
||||
|
||||
expect(collectTypes(children)).toEqual([
|
||||
'strong',
|
||||
'text',
|
||||
'emphasis',
|
||||
'text',
|
||||
'strikethrough',
|
||||
'text',
|
||||
'math_inline',
|
||||
])
|
||||
expect(children[0]?.children?.[0]?.content).toBe('粗体')
|
||||
expect(children[2]?.children?.[0]?.content).toBe('斜体')
|
||||
expect(children[4]?.children?.[0]?.content).toBe('删除线')
|
||||
expect(children[6]?.content).toBe('E = mc^2')
|
||||
})
|
||||
|
||||
it('keeps underscore-based and triple-asterisk formatting inside custom component content', () => {
|
||||
const underscoreChildren = parseCustomComponentChildren('__粗体__ 和 _斜体_ 和 ~~删除线~~ 和 $E = mc^2$')
|
||||
expect(collectTypes(underscoreChildren)).toEqual([
|
||||
'strong',
|
||||
'text',
|
||||
'emphasis',
|
||||
'text',
|
||||
'strikethrough',
|
||||
'text',
|
||||
'math_inline',
|
||||
])
|
||||
|
||||
const tripleChildren = parseCustomComponentChildren('***粗斜体*** 和 ~~删除线~~ 和 $E = mc^2$')
|
||||
const outer = tripleChildren[0]
|
||||
const inner = outer?.children?.[0]
|
||||
expect(['strong', 'emphasis']).toContain(outer?.type)
|
||||
expect(['strong', 'emphasis']).toContain(inner?.type)
|
||||
expect(inner?.type).not.toBe(outer?.type)
|
||||
expect(inner?.children?.[0]?.content).toBe('粗斜体')
|
||||
expect(collectTypes(tripleChildren.slice(1))).toEqual([
|
||||
'text',
|
||||
'strikethrough',
|
||||
'text',
|
||||
'math_inline',
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps escaped delimiters literal while still parsing later real formatting inside custom component content', () => {
|
||||
const children = parseCustomComponentChildren('\\*\\*字面量\\*\\* 和 **真实粗体** 和 $E = mc^2$')
|
||||
|
||||
expect(collectTypes(children)).toEqual([
|
||||
'text',
|
||||
'strong',
|
||||
'text',
|
||||
'math_inline',
|
||||
])
|
||||
expect(children[0]?.content).toBe('**字面量** 和 ')
|
||||
expect(children[1]?.children?.[0]?.content).toBe('真实粗体')
|
||||
expect(children[3]?.content).toBe('E = mc^2')
|
||||
})
|
||||
|
||||
it('keeps math recoverable with malformed formatting inside custom component content', () => {
|
||||
const children = parseCustomComponentChildren('**粗体 *未闭合 和 ~~删除线~~ 和 $E = mc^2$')
|
||||
|
||||
const mathNodes = collectByType(children, 'math_inline')
|
||||
expect(mathNodes).toHaveLength(1)
|
||||
expect(mathNodes[0]?.content).toBe('E = mc^2')
|
||||
|
||||
const serialized = JSON.stringify(children)
|
||||
expect(serialized).toContain('未闭合')
|
||||
expect(serialized).toContain('删除线')
|
||||
})
|
||||
})
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getMarkdown, parseMarkdownToStructure } from '../src'
|
||||
|
||||
describe('issue 380 html wrapper markdown regression', () => {
|
||||
it('keeps markdown nested in a span wrapper as structured children without leaking duplicate top-level nodes', () => {
|
||||
const markdown = `<span style="font-size: 12px;">
|
||||
|
||||
🗺️【环境状态】
|
||||
- 地点:石溪村,李东的茅屋
|
||||
- 时间:4/12 周四 上午07:00
|
||||
- 环境:阳光明媚,空气清新,茅屋内简陋但整洁,远处山峦笼罩在薄雾中,偶尔传来鸟鸣声
|
||||
|
||||
📊【角色状态】
|
||||
- 生理:健康(轻微饥饿)
|
||||
- 物品:锄头(自家)、简易弓箭(自家)、5个铜币(自家)、一些干粮(自家)
|
||||
- 技能:农耕(基础)、陷阱设置(基础)、生存知识(基础)
|
||||
- 关系:与顾客关系良好(+)
|
||||
|
||||
📖【目标与线索】
|
||||
- 已知线索:无
|
||||
- 短期目标:无
|
||||
- 长期目标:无
|
||||
***
|
||||
|
||||
|
||||
🎯【选项】
|
||||
1. 去田里劳作,争取多收成些粮食卖钱
|
||||
2. 上山检查之前设置的陷阱,看有没有捕到猎
|
||||
3. 直接去清风镇看看情况,虽然路途遥远
|
||||
4. ……
|
||||
|
||||
</span>`
|
||||
|
||||
const md = getMarkdown('issue-380-structured')
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
|
||||
|
||||
expect(nodes).toHaveLength(1)
|
||||
expect(nodes[0]?.type).toBe('html_block')
|
||||
expect(nodes[0]?.tag).toBe('span')
|
||||
expect(nodes[0]?.attrs).toEqual([['style', 'font-size: 12px;']])
|
||||
expect(nodes[0]?.raw).toBe(markdown)
|
||||
expect(nodes[0]?.children?.map((child: any) => child?.type)).toEqual([
|
||||
'paragraph',
|
||||
'list',
|
||||
'paragraph',
|
||||
'list',
|
||||
'paragraph',
|
||||
'list',
|
||||
'thematic_break',
|
||||
'paragraph',
|
||||
'list',
|
||||
])
|
||||
expect(nodes[0]?.children?.[0]?.children?.[0]?.content).toBe('🗺️【环境状态】')
|
||||
expect(nodes[0]?.children?.[1]?.items).toHaveLength(3)
|
||||
expect(nodes[0]?.children?.[8]?.ordered).toBe(true)
|
||||
expect(nodes[0]?.children?.[8]?.items).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('structures simple block markdown inside span and removes leaked list duplicates', () => {
|
||||
const markdown = `<span>
|
||||
|
||||
- a
|
||||
- b
|
||||
|
||||
</span>`
|
||||
const md = getMarkdown('issue-380-simple')
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
|
||||
|
||||
expect(nodes).toHaveLength(1)
|
||||
expect(nodes[0]?.type).toBe('html_block')
|
||||
expect(nodes[0]?.tag).toBe('span')
|
||||
expect(nodes[0]?.children?.map((child: any) => child?.type)).toEqual(['list'])
|
||||
expect(nodes[0]?.children?.[0]?.items).toHaveLength(2)
|
||||
expect(nodes[0]?.children?.[0]?.items?.[0]?.children?.[0]?.children?.[0]?.content).toBe('a')
|
||||
expect(nodes[0]?.children?.[0]?.items?.[1]?.children?.[0]?.children?.[0]?.content).toBe('b')
|
||||
})
|
||||
|
||||
it('keeps pure html wrappers unstructured when they do not actually contain markdown blocks', () => {
|
||||
const markdown = '<div><strong>Block HTML</strong></div>'
|
||||
const md = getMarkdown('issue-380-pure-html')
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
|
||||
|
||||
expect(nodes).toHaveLength(1)
|
||||
expect(nodes[0]?.type).toBe('html_block')
|
||||
expect(nodes[0]?.tag).toBe('div')
|
||||
expect(nodes[0]?.children).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not structure blocked tags even when their inner content looks like markdown blocks', () => {
|
||||
const markdown = `<script>
|
||||
|
||||
- a
|
||||
- b
|
||||
|
||||
</script>`
|
||||
const md = getMarkdown('issue-380-blocked-tag')
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
|
||||
|
||||
expect(nodes).toHaveLength(1)
|
||||
expect(nodes[0]?.type).toBe('html_block')
|
||||
expect(nodes[0]?.tag).toBe('script')
|
||||
expect(nodes[0]?.children).toBeUndefined()
|
||||
expect(nodes[0]?.raw).toBe(markdown)
|
||||
})
|
||||
|
||||
it('keeps literal-content tags unstructured even when their inner content looks like markdown blocks', () => {
|
||||
const samples = ['pre', 'style', 'textarea'] as const
|
||||
|
||||
for (const tag of samples) {
|
||||
const markdown = `<${tag}>
|
||||
|
||||
- a
|
||||
- b
|
||||
|
||||
</${tag}>`
|
||||
const md = getMarkdown(`issue-380-literal-${tag}`)
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
|
||||
|
||||
expect(nodes).toHaveLength(1)
|
||||
expect(nodes[0]?.type).toBe('html_block')
|
||||
expect(nodes[0]?.tag).toBe(tag)
|
||||
expect(nodes[0]?.children).toBeUndefined()
|
||||
expect(nodes[0]?.raw).toBe(markdown)
|
||||
}
|
||||
})
|
||||
|
||||
it('structures nested html wrappers when the inner wrapper already contains markdown blocks', () => {
|
||||
const markdown = `<div>
|
||||
<div>
|
||||
|
||||
- a
|
||||
|
||||
</div>
|
||||
</div>`
|
||||
const md = getMarkdown('issue-380-nested')
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
|
||||
|
||||
expect(nodes).toHaveLength(1)
|
||||
expect(nodes[0]?.type).toBe('html_block')
|
||||
expect(nodes[0]?.tag).toBe('div')
|
||||
expect(nodes[0]?.children?.map((child: any) => child?.type)).toEqual(['html_block'])
|
||||
expect(nodes[0]?.children?.[0]?.tag).toBe('div')
|
||||
expect(nodes[0]?.children?.[0]?.children?.map((child: any) => child?.type)).toEqual(['list'])
|
||||
expect(nodes[0]?.children?.[0]?.children?.[0]?.items).toHaveLength(1)
|
||||
expect(nodes[0]?.children?.[0]?.children?.[0]?.items?.[0]?.children?.[0]?.children?.[0]?.content).toBe('a')
|
||||
})
|
||||
|
||||
it('keeps streaming span wrappers stable before the closing tag arrives', () => {
|
||||
const markdown = `<span>
|
||||
|
||||
- a
|
||||
- b`
|
||||
const md = getMarkdown('issue-380-streaming')
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: false }) as any[]
|
||||
|
||||
expect(nodes).toHaveLength(1)
|
||||
expect(nodes[0]?.type).toBe('html_block')
|
||||
expect(nodes[0]?.tag).toBe('span')
|
||||
expect(nodes[0]?.loading).toBe(true)
|
||||
expect(nodes[0]?.children?.map((child: any) => child?.type)).toEqual(['list'])
|
||||
expect(nodes[0]?.children?.[0]?.items).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getMarkdown, parseMarkdownToStructure } from '../src'
|
||||
|
||||
const REAL_WORLD_MULTILINE_INPUT = `$2.897771955 times 10^{-3}text{m·K}$^[1]^
|
||||
测试<sup>[3]</sup>。
|
||||
$x$^[1]^
|
||||
$x$ ^[1]^
|
||||
测试^[1]^
|
||||
$2.897771955 \\times 10^{-3}\\text{m·K}$^[1]^
|
||||
<sup>[1]</sup>
|
||||
测试<sup>[12]</sup>结束
|
||||
A<sup>[3]</sup>B
|
||||
$x$^[1]^
|
||||
测试^[1]^
|
||||
<sup>[3]</sup>
|
||||
测试<sup>[12]</sup>结束`
|
||||
|
||||
function parse(markdown: string, final = true) {
|
||||
const md = getMarkdown('issue-386')
|
||||
return parseMarkdownToStructure(markdown, md, { final }) as any[]
|
||||
}
|
||||
|
||||
function collectByType(value: any, targetType: string): any[] {
|
||||
const out: any[] = []
|
||||
const walk = (node: any) => {
|
||||
if (!node)
|
||||
return
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(walk)
|
||||
return
|
||||
}
|
||||
if (node.type === targetType)
|
||||
out.push(node)
|
||||
if (Array.isArray(node.children))
|
||||
node.children.forEach(walk)
|
||||
if (Array.isArray(node.items))
|
||||
node.items.forEach(walk)
|
||||
}
|
||||
walk(value)
|
||||
return out
|
||||
}
|
||||
|
||||
describe('issue #386 inline math suffix regressions', () => {
|
||||
it.each([true, false])('keeps superscript syntax immediately after inline math when final=%s', (final) => {
|
||||
const nodes = parse('$2.897771955 times 10^{-3}text{m·K}$^[1]^', final)
|
||||
const paragraph = nodes[0] as any
|
||||
|
||||
expect(paragraph?.type).toBe('paragraph')
|
||||
expect(paragraph?.children?.map((child: any) => child.type)).toEqual([
|
||||
'math_inline',
|
||||
'superscript',
|
||||
])
|
||||
expect(paragraph?.children?.[1]?.children?.map((child: any) => child.content).join('')).toBe('[1]')
|
||||
|
||||
const textNodes = collectByType(paragraph, 'text')
|
||||
expect(textNodes.some((node: any) => node.content === '^[1]^')).toBe(false)
|
||||
})
|
||||
|
||||
it.each([true, false])('keeps footnote references immediately after inline math when final=%s', (final) => {
|
||||
const nodes = parse('$x$[^1]\n\n[^1]: note', final)
|
||||
const paragraph = nodes[0] as any
|
||||
|
||||
expect(paragraph?.type).toBe('paragraph')
|
||||
expect(paragraph?.children?.map((child: any) => child.type)).toEqual([
|
||||
'math_inline',
|
||||
'footnote_reference',
|
||||
])
|
||||
expect(paragraph?.children?.[1]?.id).toBe('1')
|
||||
|
||||
const footnotes = collectByType(nodes, 'footnote')
|
||||
expect(footnotes).toHaveLength(1)
|
||||
expect(footnotes[0]?.id).toBe('1')
|
||||
expect(collectByType(footnotes[0], 'text').some((node: any) => node.content === 'note')).toBe(true)
|
||||
})
|
||||
|
||||
it.each([true, false])('preserves brackets inside sup html content when final=%s', (final) => {
|
||||
const nodes = parse('测试<sup>[3]</sup>。', final)
|
||||
const paragraph = nodes[0] as any
|
||||
const htmlInline = collectByType(paragraph, 'html_inline')[0] as any
|
||||
|
||||
expect(htmlInline).toBeDefined()
|
||||
expect(htmlInline.content).toBe('<sup>[3]</sup>')
|
||||
expect(htmlInline.children).toEqual([
|
||||
{
|
||||
type: 'text',
|
||||
content: '[3]',
|
||||
raw: '[3]',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it.each([true, false])('preserves brackets inside generic inline html content when final=%s', (final) => {
|
||||
const nodes = parse('<span>[3]</span>', final)
|
||||
const paragraph = nodes[0] as any
|
||||
const htmlInline = collectByType(paragraph, 'html_inline')[0] as any
|
||||
|
||||
expect(htmlInline).toBeDefined()
|
||||
expect(htmlInline.content).toBe('<span>[3]</span>')
|
||||
expect(htmlInline.children).toEqual([
|
||||
{
|
||||
type: 'text',
|
||||
content: '[3]',
|
||||
raw: '[3]',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it.each([true, false])('parses the real multiline issue-386 input without leaking raw superscript syntax when final=%s', (final) => {
|
||||
const nodes = parse(REAL_WORLD_MULTILINE_INPUT, final)
|
||||
const paragraph = nodes[0] as any
|
||||
const superscripts = collectByType(paragraph, 'superscript')
|
||||
const htmlInlineNodes = collectByType(paragraph, 'html_inline')
|
||||
const textNodes = collectByType(paragraph, 'text')
|
||||
|
||||
expect(paragraph?.type).toBe('paragraph')
|
||||
expect(superscripts).toHaveLength(7)
|
||||
expect(superscripts.map((node: any) => node.children?.map((child: any) => child.content).join(''))).toEqual([
|
||||
'[1]',
|
||||
'[1]',
|
||||
'[1]',
|
||||
'[1]',
|
||||
'[1]',
|
||||
'[1]',
|
||||
'[1]',
|
||||
])
|
||||
expect(textNodes.some((node: any) => String(node.content).includes('^[1]^'))).toBe(false)
|
||||
expect(htmlInlineNodes.map((node: any) => node.children?.map((child: any) => child.content).join(''))).toEqual([
|
||||
'[3]',
|
||||
'[1]',
|
||||
'[12]',
|
||||
'[3]',
|
||||
'[3]',
|
||||
'[12]',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getMarkdown, parseMarkdownToStructure } from '../src'
|
||||
|
||||
describe('link attrs parsing', () => {
|
||||
it('preserves custom attrs injected on link_open tokens', () => {
|
||||
const md = getMarkdown('link-attrs')
|
||||
const nodes = parseMarkdownToStructure('[Example](https://example.com)', md, {
|
||||
preTransformTokens(tokens: any[]) {
|
||||
for (const token of tokens) {
|
||||
if (token?.type !== 'inline' || !Array.isArray(token.children))
|
||||
continue
|
||||
for (const child of token.children) {
|
||||
if (child?.type !== 'link_open')
|
||||
continue
|
||||
if (typeof child.attrSet === 'function') {
|
||||
child.attrSet('target', '_self')
|
||||
child.attrSet('data-track', 'cta')
|
||||
}
|
||||
else {
|
||||
child.attrs = [...(child.attrs ?? []), ['target', '_self'], ['data-track', 'cta']]
|
||||
}
|
||||
}
|
||||
}
|
||||
return tokens
|
||||
},
|
||||
})
|
||||
|
||||
const para = nodes[0] as any
|
||||
const link = para.children.find((c: any) => c.type === 'link')
|
||||
expect(link).toBeDefined()
|
||||
expect(link.attrs).toEqual(expect.arrayContaining([
|
||||
['href', 'https://example.com'],
|
||||
['target', '_self'],
|
||||
['data-track', 'cta'],
|
||||
]))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,258 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { textIncludes } from '../../../test/utils/midstate-utils'
|
||||
import { getMarkdown, parseMarkdownToStructure } from '../src'
|
||||
|
||||
const md = getMarkdown('test')
|
||||
|
||||
function collectByType(nodes: any[], type: string) {
|
||||
const out: any[] = []
|
||||
const walk = (n: any) => {
|
||||
if (!n)
|
||||
return
|
||||
if (n.type === type)
|
||||
out.push(n)
|
||||
if (Array.isArray(n.children))
|
||||
n.children.forEach(walk)
|
||||
if (Array.isArray(n.items))
|
||||
n.items.forEach(walk)
|
||||
}
|
||||
nodes.forEach(walk)
|
||||
return out
|
||||
}
|
||||
|
||||
describe('special link cases', () => {
|
||||
it('parses link with numeric brackets in link text: [[8]](url)', () => {
|
||||
const markdown = '建议采用以下接口方案:[[8]](https://markstream-vue-docs.simonhe.me/)'
|
||||
const nodes = parseMarkdownToStructure(markdown, md)
|
||||
|
||||
// Collect all link nodes
|
||||
const links: any[] = []
|
||||
const walk = (n: any) => {
|
||||
if (!n)
|
||||
return
|
||||
if (n.type === 'link')
|
||||
links.push(n)
|
||||
if (Array.isArray(n.children))
|
||||
n.children.forEach(walk)
|
||||
if (Array.isArray(n.items))
|
||||
n.items.forEach(walk)
|
||||
}
|
||||
nodes.forEach(walk)
|
||||
|
||||
// Should have exactly one link
|
||||
expect(links.length).toBe(1)
|
||||
const link = links[0]
|
||||
|
||||
// Verify link properties
|
||||
expect(link.href).toBe('https://markstream-vue-docs.simonhe.me/')
|
||||
expect(link.loading).toBe(false)
|
||||
|
||||
// Verify link text contains [8]
|
||||
// The link text should be "[8]" (the brackets are part of the link text)
|
||||
const linkText = link.text || ''
|
||||
const childText = (link.children || [])
|
||||
.map((c: any) => (c.content ?? c.text ?? ''))
|
||||
.join('')
|
||||
const fullText = linkText || childText
|
||||
|
||||
expect(fullText).toBe('[8]')
|
||||
|
||||
// Verify the paragraph contains both the prefix text and the link
|
||||
const para = nodes.find((n: any) => n.type === 'paragraph') as any
|
||||
expect(para).toBeTruthy()
|
||||
expect(textIncludes(para, '建议采用以下接口方案:')).toBe(true)
|
||||
expect(para.children.some((c: any) => c.type === 'link')).toBe(true)
|
||||
})
|
||||
|
||||
it('parses link with emphasis markers in link text: [DR **(Danmarks Radio)](url)', () => {
|
||||
const markdown = '[DR **(Danmarks Radio)](https://www.dr.dk/nyheder)'
|
||||
const nodes = parseMarkdownToStructure(markdown, md)
|
||||
|
||||
// Collect all link nodes
|
||||
const links: any[] = []
|
||||
const walk = (n: any) => {
|
||||
if (!n)
|
||||
return
|
||||
if (n.type === 'link')
|
||||
links.push(n)
|
||||
if (Array.isArray(n.children))
|
||||
n.children.forEach(walk)
|
||||
if (Array.isArray(n.items))
|
||||
n.items.forEach(walk)
|
||||
}
|
||||
nodes.forEach(walk)
|
||||
|
||||
// Should have exactly one link
|
||||
expect(links.length).toBe(1)
|
||||
const link = links[0]
|
||||
|
||||
// Verify link properties
|
||||
expect(link.href).toBe('https://www.dr.dk/nyheder')
|
||||
expect(link.loading).toBe(false)
|
||||
|
||||
// Should contain "DR" and "Danmarks Radio"
|
||||
expect(textIncludes(link, 'DR')).toBe(true)
|
||||
expect(textIncludes(link, 'Danmarks Radio')).toBe(true)
|
||||
expect(textIncludes(link, '(Danmarks Radio)')).toBe(true)
|
||||
|
||||
// Verify the paragraph contains the link
|
||||
const para = nodes.find((n: any) => n.type === 'paragraph') as any
|
||||
expect(para).toBeTruthy()
|
||||
expect(para.children.some((c: any) => c.type === 'link')).toBe(true)
|
||||
})
|
||||
|
||||
it('parses multiple links with special characters in same paragraph', () => {
|
||||
const markdown = '建议采用以下接口方案:[[8]](https://markstream-vue-docs.simonhe.me/)\n\n[DR **(Danmarks Radio)](https://www.dr.dk/nyheder)'
|
||||
const nodes = parseMarkdownToStructure(markdown, md)
|
||||
|
||||
// Collect all link nodes
|
||||
const links: any[] = []
|
||||
const walk = (n: any) => {
|
||||
if (!n)
|
||||
return
|
||||
if (n.type === 'link')
|
||||
links.push(n)
|
||||
if (Array.isArray(n.children))
|
||||
n.children.forEach(walk)
|
||||
if (Array.isArray(n.items))
|
||||
n.items.forEach(walk)
|
||||
}
|
||||
nodes.forEach(walk)
|
||||
|
||||
// Should have exactly two links
|
||||
expect(links.length).toBe(2)
|
||||
|
||||
// First link: [[8]](url)
|
||||
const firstLink = links.find(l => l.href === 'https://markstream-vue-docs.simonhe.me/')
|
||||
expect(firstLink).toBeDefined()
|
||||
expect(firstLink?.loading).toBe(false)
|
||||
const firstLinkText = firstLink?.text || (firstLink?.children || []).map((c: any) => c.content || '').join('')
|
||||
expect(firstLinkText).toBe('[8]')
|
||||
|
||||
// Second link: [DR **(Danmarks Radio)](url)
|
||||
const secondLink = links.find(l => l.href === 'https://www.dr.dk/nyheder')
|
||||
expect(secondLink).toBeDefined()
|
||||
expect(secondLink?.loading).toBe(false)
|
||||
expect(textIncludes(secondLink, 'DR')).toBe(true)
|
||||
expect(textIncludes(secondLink, 'Danmarks Radio')).toBe(true)
|
||||
})
|
||||
|
||||
it('parses standalone numeric bracket link without prefix text', () => {
|
||||
const markdown = '[[8]](https://example.com)'
|
||||
const nodes = parseMarkdownToStructure(markdown, md)
|
||||
|
||||
const links: any[] = []
|
||||
const walk = (n: any) => {
|
||||
if (!n)
|
||||
return
|
||||
if (n.type === 'link')
|
||||
links.push(n)
|
||||
if (Array.isArray(n.children))
|
||||
n.children.forEach(walk)
|
||||
}
|
||||
nodes.forEach(walk)
|
||||
|
||||
expect(links.length).toBe(1)
|
||||
expect(links[0].href).toBe('https://example.com')
|
||||
expect(links[0].text || links[0].children?.map((c: any) => c.content || '').join('')).toBe('[8]')
|
||||
})
|
||||
|
||||
it('distinguishes between reference [8] and link [[8]](url)', () => {
|
||||
// Standalone [8] should be a reference
|
||||
const referenceMarkdown = '请参考 [8] 了解更多信息'
|
||||
const referenceNodes = parseMarkdownToStructure(referenceMarkdown, md)
|
||||
|
||||
const references: any[] = []
|
||||
const walkRef = (n: any) => {
|
||||
if (!n)
|
||||
return
|
||||
if (n.type === 'reference')
|
||||
references.push(n)
|
||||
if (Array.isArray(n.children))
|
||||
n.children.forEach(walkRef)
|
||||
}
|
||||
referenceNodes.forEach(walkRef)
|
||||
|
||||
// [8] as standalone should be parsed as reference (if reference rule matches)
|
||||
// But if it's followed by ](, it should be a link
|
||||
const linkMarkdown = '请参考 [[8]](https://example.com) 了解更多信息'
|
||||
const linkNodes = parseMarkdownToStructure(linkMarkdown, md)
|
||||
|
||||
const links: any[] = []
|
||||
const walkLink = (n: any) => {
|
||||
if (!n)
|
||||
return
|
||||
if (n.type === 'link')
|
||||
links.push(n)
|
||||
if (Array.isArray(n.children))
|
||||
n.children.forEach(walkLink)
|
||||
}
|
||||
linkNodes.forEach(walkLink)
|
||||
|
||||
// The link version should have a link node
|
||||
expect(links.length).toBeGreaterThan(0)
|
||||
const link = links.find(l => l.href === 'https://example.com')
|
||||
expect(link).toBeDefined()
|
||||
expect(link?.text || link?.children?.map((c: any) => c.content || '').join('')).toBe('[8]')
|
||||
})
|
||||
|
||||
it('preserves normal reference-style links when the label is not numeric', () => {
|
||||
const markdown = `请看 [1][source]
|
||||
|
||||
[source]: https://example.com`
|
||||
const nodes = parseMarkdownToStructure(markdown, md)
|
||||
const links = collectByType(nodes, 'link')
|
||||
const references = collectByType(nodes, 'reference')
|
||||
|
||||
expect(links).toHaveLength(1)
|
||||
expect(links[0].href).toBe('https://example.com')
|
||||
expect(links[0].text || links[0].children?.map((c: any) => c.content || '').join('')).toBe('1')
|
||||
expect(references).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('parses numeric references when they are adjacent to surrounding text', () => {
|
||||
const cases = [
|
||||
'随便输入一些文字[1]',
|
||||
'随便输入一些文字[1]后面还有文字',
|
||||
'随便输入一些文字[1] ',
|
||||
'随便输入一些文字[1] (空格+文字)',
|
||||
]
|
||||
|
||||
for (const markdown of cases) {
|
||||
const nodes = parseMarkdownToStructure(markdown, md)
|
||||
const references = collectByType(nodes, 'reference')
|
||||
|
||||
expect(references, markdown).toHaveLength(1)
|
||||
expect(references[0].id).toBe('1')
|
||||
expect(textIncludes(nodes, '随便输入一些文字')).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('parses a numeric reference immediately followed by a normal inline link', () => {
|
||||
const cases = [
|
||||
'随便输入一些文字 [1][百度](www.baidu.com)',
|
||||
'随便输入一些文字 [1] [百度](www.baidu.com)',
|
||||
]
|
||||
|
||||
for (const markdown of cases) {
|
||||
const nodes = parseMarkdownToStructure(markdown, md)
|
||||
const references = collectByType(nodes, 'reference')
|
||||
const links = collectByType(nodes, 'link')
|
||||
|
||||
expect(references, markdown).toHaveLength(1)
|
||||
expect(references[0].id).toBe('1')
|
||||
expect(links, markdown).toHaveLength(1)
|
||||
expect(links[0].href).toBe('www.baidu.com')
|
||||
expect(textIncludes(links[0], '百度')).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('parses chained numeric references without requiring spaces', () => {
|
||||
const nodes = parseMarkdownToStructure('随便输入一些文字[1][2][3]', md)
|
||||
const references = collectByType(nodes, 'reference')
|
||||
|
||||
expect(references).toHaveLength(3)
|
||||
expect(references.map(ref => ref.id)).toEqual(['1', '2', '3'])
|
||||
expect(textIncludes(nodes, '随便输入一些文字')).toBe(true)
|
||||
})
|
||||
})
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getMarkdown, parseMarkdownToStructure } from '../src'
|
||||
|
||||
function collectByType(nodes: any, type: string, out: any[] = []) {
|
||||
if (!nodes)
|
||||
return out
|
||||
if (Array.isArray(nodes)) {
|
||||
nodes.forEach(node => collectByType(node, type, out))
|
||||
return out
|
||||
}
|
||||
if (nodes.type === type)
|
||||
out.push(nodes)
|
||||
if (Array.isArray(nodes.children))
|
||||
nodes.children.forEach((child: any) => collectByType(child, type, out))
|
||||
if (Array.isArray(nodes.items))
|
||||
nodes.items.forEach((item: any) => collectByType(item, type, out))
|
||||
return out
|
||||
}
|
||||
|
||||
function collectText(nodes: any) {
|
||||
return collectByType(nodes, 'text')
|
||||
.map((node: any) => String(node.content ?? ''))
|
||||
.join('')
|
||||
}
|
||||
|
||||
function expectListStreamingTextToStayVisible(options: {
|
||||
id: string
|
||||
full: string
|
||||
explanation: string
|
||||
suffix: string
|
||||
protectedType?: string
|
||||
}) {
|
||||
const md = getMarkdown(options.id)
|
||||
const explanationReadyAt = options.full.indexOf(options.explanation) + options.explanation.length
|
||||
const suffixStartAt = options.full.indexOf(options.suffix)
|
||||
|
||||
for (let i = explanationReadyAt; i <= options.full.length; i++) {
|
||||
const chunk = options.full.slice(0, i)
|
||||
const nodes = parseMarkdownToStructure(chunk, md, { final: false })
|
||||
const text = collectText(nodes)
|
||||
|
||||
expect(text, `prefix(${i}): ${chunk}`).toContain(options.explanation)
|
||||
expect(text.match(new RegExp(options.explanation, 'g')) ?? [], `prefix(${i}): ${chunk}`).toHaveLength(1)
|
||||
|
||||
if (i > suffixStartAt) {
|
||||
const expectedSuffix = options.full.slice(suffixStartAt, i)
|
||||
expect(text, `prefix(${i}): ${chunk}`).toContain(expectedSuffix)
|
||||
|
||||
if (options.protectedType) {
|
||||
const protectedNodes = collectByType(nodes, options.protectedType)
|
||||
expect(
|
||||
protectedNodes.some((node: any) => JSON.stringify(node).includes(expectedSuffix)),
|
||||
`prefix(${i}): ${chunk}`,
|
||||
).toBe(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('list inline streaming regression', () => {
|
||||
it('keeps explanatory list text stable while inline code opens, grows, closes, and is followed by more text', () => {
|
||||
const full = `- **计算工具验证**
|
||||
通过数学计算工具确认结果:
|
||||
\`363 ÷ 15,135 × 100 = 2.39841427...\`
|
||||
后续说明继续输出`
|
||||
|
||||
expectListStreamingTextToStayVisible({
|
||||
id: 'list-inline-code-streaming-regression',
|
||||
full,
|
||||
explanation: '通过数学计算工具确认结果:',
|
||||
suffix: '后续说明继续输出',
|
||||
protectedType: 'inline_code',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps explanatory list text stable while an inline link opens, closes, and is followed by more text', () => {
|
||||
const full = `- **链接验证**
|
||||
通过链接工具确认结果:
|
||||
[查看详情](https://example.com)
|
||||
后续说明继续输出`
|
||||
|
||||
expectListStreamingTextToStayVisible({
|
||||
id: 'list-inline-link-streaming-regression',
|
||||
full,
|
||||
explanation: '通过链接工具确认结果:',
|
||||
suffix: '后续说明继续输出',
|
||||
protectedType: 'link',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps explanatory list text stable while emphasis opens, closes, and is followed by more text', () => {
|
||||
const full = `- **强调说明**
|
||||
通过人工复核确认结果:
|
||||
*结果待确认*
|
||||
后续说明继续输出`
|
||||
|
||||
expectListStreamingTextToStayVisible({
|
||||
id: 'list-emphasis-streaming-regression',
|
||||
full,
|
||||
explanation: '通过人工复核确认结果:',
|
||||
suffix: '后续说明继续输出',
|
||||
protectedType: 'emphasis',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps explanatory list text stable while inline math closes and is followed by more text', () => {
|
||||
const full = `- **数学验证**
|
||||
通过公式确认结果:
|
||||
$x^2 + y^2$
|
||||
后续说明继续输出`
|
||||
|
||||
expectListStreamingTextToStayVisible({
|
||||
id: 'list-math-inline-streaming-regression',
|
||||
full,
|
||||
explanation: '通过公式确认结果:',
|
||||
suffix: '后续说明继续输出',
|
||||
protectedType: 'math_inline',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps explanatory list text stable while a numeric reference appears and is followed by more text', () => {
|
||||
const full = `- **参考验证**
|
||||
通过参考编号确认结果:
|
||||
[1]
|
||||
后续说明继续输出`
|
||||
|
||||
expectListStreamingTextToStayVisible({
|
||||
id: 'list-reference-streaming-regression',
|
||||
full,
|
||||
explanation: '通过参考编号确认结果:',
|
||||
suffix: '后续说明继续输出',
|
||||
protectedType: 'reference',
|
||||
})
|
||||
})
|
||||
})
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getMarkdown, parseMarkdownToStructure } from '../src'
|
||||
|
||||
function flatten(nodes: any[]): any[] {
|
||||
const out: any[] = []
|
||||
for (const n of nodes ?? []) {
|
||||
out.push(n)
|
||||
if (Array.isArray(n?.children))
|
||||
out.push(...flatten(n.children))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
describe('parseMarkdownToStructure - multiple links same line', () => {
|
||||
it('parses 3 adjacent links as 3 link nodes', () => {
|
||||
const md = getMarkdown()
|
||||
const markdown = '[citation](http://url1) [citation](http://url1) [citation](http://url1)'
|
||||
const nodes = parseMarkdownToStructure(markdown, md)
|
||||
|
||||
const all = flatten(nodes)
|
||||
const links = all.filter(n => n?.type === 'link')
|
||||
|
||||
expect(links).toHaveLength(3)
|
||||
expect(links.map(l => l.href)).toEqual(['http://url1', 'http://url1', 'http://url1'])
|
||||
})
|
||||
})
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getMarkdown, parseMarkdownToStructure } from '../src'
|
||||
|
||||
function collectByType(nodes: any[], type: string) {
|
||||
const out: any[] = []
|
||||
const walk = (node: any) => {
|
||||
if (!node)
|
||||
return
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(walk)
|
||||
return
|
||||
}
|
||||
if (node.type === type)
|
||||
out.push(node)
|
||||
if (Array.isArray(node.children))
|
||||
node.children.forEach(walk)
|
||||
if (Array.isArray(node.items))
|
||||
node.items.forEach(walk)
|
||||
}
|
||||
walk(nodes)
|
||||
return out
|
||||
}
|
||||
|
||||
function collectText(nodes: any[]) {
|
||||
return collectByType(nodes, 'text').map((node: any) => String(node.content ?? '')).join('')
|
||||
}
|
||||
|
||||
describe('numeric reference streaming stability', () => {
|
||||
it('keeps completed numeric references stable during char-by-char chained references', () => {
|
||||
const md = getMarkdown('numeric-reference-streaming-chain')
|
||||
const full = '随便输入一些文字[1][2][3]'
|
||||
const firstReady = full.indexOf('[1]') + '[1]'.length
|
||||
const secondOpens = full.indexOf('[2]')
|
||||
const secondReady = secondOpens + '[2]'.length
|
||||
const thirdReady = full.indexOf('[3]') + '[3]'.length
|
||||
|
||||
for (let i = 1; i <= full.length; i++) {
|
||||
const chunk = full.slice(0, i)
|
||||
const nodes = parseMarkdownToStructure(chunk, md, { final: false })
|
||||
const ids = collectByType(nodes, 'reference').map((node: any) => String(node.id ?? ''))
|
||||
|
||||
if (i >= firstReady)
|
||||
expect(ids[0], `prefix(${i}): ${chunk}`).toBe('1')
|
||||
if (i > secondOpens)
|
||||
expect(ids[0], `prefix(${i}): ${chunk}`).toBe('1')
|
||||
if (i >= secondReady)
|
||||
expect(ids.slice(0, 2), `prefix(${i}): ${chunk}`).toEqual(['1', '2'])
|
||||
if (i >= thirdReady)
|
||||
expect(ids.slice(0, 3), `prefix(${i}): ${chunk}`).toEqual(['1', '2', '3'])
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the numeric reference once an adjacent inline link becomes distinguishable', () => {
|
||||
const md = getMarkdown('numeric-reference-streaming-link')
|
||||
const full = '随便输入一些文字 [1][百度](www.baidu.com)'
|
||||
const distinguishableAt = full.indexOf('[百度](') + '[百度]('.length
|
||||
|
||||
for (let i = 1; i <= full.length; i++) {
|
||||
const chunk = full.slice(0, i)
|
||||
const nodes = parseMarkdownToStructure(chunk, md, { final: false })
|
||||
const ids = collectByType(nodes, 'reference').map((node: any) => String(node.id ?? ''))
|
||||
const links = collectByType(nodes, 'link')
|
||||
|
||||
if (i >= distinguishableAt) {
|
||||
expect(ids[0], `prefix(${i}): ${chunk}`).toBe('1')
|
||||
expect(collectText(nodes), `prefix(${i}): ${chunk}`).not.toContain('[1]')
|
||||
expect(links, `prefix(${i}): ${chunk}`).toHaveLength(1)
|
||||
expect(String(links[0]?.text ?? ''), `prefix(${i}): ${chunk}`).toContain('百度')
|
||||
expect(Boolean(links[0]?.loading), `prefix(${i}): ${chunk}`).toBe(i < full.length)
|
||||
}
|
||||
}
|
||||
|
||||
const finalNodes = parseMarkdownToStructure(full, md, { final: true })
|
||||
const finalIds = collectByType(finalNodes, 'reference').map((node: any) => String(node.id ?? ''))
|
||||
const links = collectByType(finalNodes, 'link')
|
||||
|
||||
expect(finalIds).toEqual(['1'])
|
||||
expect(links).toHaveLength(1)
|
||||
expect(String(links[0]?.href ?? '')).toBe('www.baidu.com')
|
||||
})
|
||||
|
||||
it('keeps the completed numeric reference while trailing text streams in', () => {
|
||||
const md = getMarkdown('numeric-reference-streaming-suffix')
|
||||
const full = '随便输入一些文字[1]后面还有文字'
|
||||
const readyAt = full.indexOf('[1]') + '[1]'.length
|
||||
|
||||
for (let i = readyAt; i <= full.length; i++) {
|
||||
const chunk = full.slice(0, i)
|
||||
const nodes = parseMarkdownToStructure(chunk, md, { final: false })
|
||||
const ids = collectByType(nodes, 'reference').map((node: any) => String(node.id ?? ''))
|
||||
|
||||
expect(ids[0], `prefix(${i}): ${chunk}`).toBe('1')
|
||||
}
|
||||
})
|
||||
|
||||
it('still respects validateLink when the trailing markdown link is being recovered', () => {
|
||||
const md = getMarkdown('numeric-reference-streaming-validate')
|
||||
md.set?.({ validateLink: (url: string) => !/^\s*javascript:/i.test(url.trim()) })
|
||||
|
||||
const chunk = '随便输入一些文字 [1][百度](javascript:alert(1'
|
||||
const nodes = parseMarkdownToStructure(chunk, md, { final: false })
|
||||
const ids = collectByType(nodes, 'reference').map((node: any) => String(node.id ?? ''))
|
||||
const links = collectByType(nodes, 'link')
|
||||
|
||||
expect(ids).toEqual(['1'])
|
||||
expect(links).toHaveLength(1)
|
||||
expect(String(links[0]?.text ?? '')).toBe('百度')
|
||||
expect(String(links[0]?.href ?? '')).not.toMatch(/javascript:/i)
|
||||
expect(Boolean(links[0]?.loading)).toBe(true)
|
||||
expect(collectText(nodes)).toContain('百度')
|
||||
})
|
||||
})
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getMarkdown, parseMarkdownToStructure } from '../src'
|
||||
|
||||
function walk(node: any, visit: (n: any) => void) {
|
||||
if (!node)
|
||||
return
|
||||
if (Array.isArray(node)) {
|
||||
for (const n of node)
|
||||
walk(n, visit)
|
||||
return
|
||||
}
|
||||
visit(node)
|
||||
if (Array.isArray(node.children))
|
||||
walk(node.children, visit)
|
||||
if (Array.isArray(node.items))
|
||||
walk(node.items, visit)
|
||||
}
|
||||
|
||||
describe('streaming ordered list - avoid transient gaps', () => {
|
||||
it('suppresses transient nested lists and leaked newlines while streaming', () => {
|
||||
const md = getMarkdown()
|
||||
const full = `法:
|
||||
|
||||
1. **递归实现**:直接
|
||||
|
||||
2. **记忆化递归(动态规划)**:使
|
||||
`
|
||||
|
||||
const marker1 = full.indexOf('1.')
|
||||
const marker2 = full.indexOf('\n\n2.')
|
||||
const cuts = [
|
||||
marker1 + 2, // "1."
|
||||
marker1 + 3, // "1. "
|
||||
marker1 + 4, // "1. *"
|
||||
marker1 + 5, // "1. **"
|
||||
full.indexOf('直接') + '直接'.length + 1, // right after item 1
|
||||
marker2 + 3, // "\n\n2"
|
||||
marker2 + 4, // "\n\n2."
|
||||
]
|
||||
.filter(n => Number.isFinite(n) && n > 0 && n <= full.length)
|
||||
.filter((n, i, arr) => arr.indexOf(n) === i)
|
||||
.sort((a, b) => a - b)
|
||||
|
||||
for (const cut of cuts) {
|
||||
const chunk = full.slice(0, cut)
|
||||
const nodes = parseMarkdownToStructure(chunk, md, { final: false })
|
||||
|
||||
const unorderedLists: any[] = []
|
||||
const newlineTextNodes: string[] = []
|
||||
const markerParagraphs: string[] = []
|
||||
walk(nodes, (n) => {
|
||||
if (n?.type === 'list' && n.ordered === false)
|
||||
unorderedLists.push(n)
|
||||
if (n?.type === 'text' && typeof n.content === 'string' && /[\r\n]/.test(n.content))
|
||||
newlineTextNodes.push(n.content)
|
||||
if (n?.type === 'paragraph' && typeof n.raw === 'string' && /^\s*\d+[.)]?\s*$/.test(n.raw))
|
||||
markerParagraphs.push(n.raw)
|
||||
})
|
||||
|
||||
expect(unorderedLists, `unexpected unordered list at cut=${cut}`).toHaveLength(0)
|
||||
expect(newlineTextNodes, `unexpected newline text nodes at cut=${cut}`).toHaveLength(0)
|
||||
expect(markerParagraphs, `unexpected marker paragraph at cut=${cut}`).toHaveLength(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getMarkdown, parseMarkdownToStructure } from '../src'
|
||||
|
||||
describe('parseMarkdownToStructure with code in link text', () => {
|
||||
it('should parse links with inline code correctly', () => {
|
||||
const md = getMarkdown()
|
||||
|
||||
const mdText1 = `the [\`npm init\` command](https://docs.npmjs.com/creating-a-package-json-file).`
|
||||
const result1 = parseMarkdownToStructure(mdText1, md, { final: true })
|
||||
// 验证结果的基本结构
|
||||
expect(result1).toBeDefined()
|
||||
expect(result1.length).toBe(1)
|
||||
expect(result1[0].type).toBe('paragraph')
|
||||
expect(result1[0].children.length).toBe(3)
|
||||
|
||||
// 验证链接节点
|
||||
const linkNode1 = result1[0].children[1]
|
||||
expect(linkNode1.type).toBe('link')
|
||||
expect(linkNode1.href).toBe('https://docs.npmjs.com/creating-a-package-json-file')
|
||||
expect(linkNode1.text).toBe('npm init command')
|
||||
expect(linkNode1.children.length).toBe(2)
|
||||
|
||||
// 验证链接中的内联代码节点
|
||||
expect(linkNode1.children[0].type).toBe('inline_code')
|
||||
expect(linkNode1.children[0].code).toBe('npm init')
|
||||
expect(linkNode1.children[1].type).toBe('text')
|
||||
expect(linkNode1.children[1].content).toBe(' command')
|
||||
|
||||
// 验证第二个链接
|
||||
const mdText2 = `[\`npm install\` command](https://docs.npmjs.com/getting-started/installing-npm-packages-locally):`
|
||||
const result2 = parseMarkdownToStructure(mdText2, md, { final: true })
|
||||
|
||||
expect(result2.length).toBe(1)
|
||||
const linkNode2 = result2[0].children[0]
|
||||
expect(linkNode2.type).toBe('link')
|
||||
expect(linkNode2.href).toBe('https://docs.npmjs.com/getting-started/installing-npm-packages-locally')
|
||||
expect(linkNode2.text).toBe('npm install command')
|
||||
expect(linkNode2.children.length).toBe(2)
|
||||
|
||||
expect(linkNode2.children[0].type).toBe('inline_code')
|
||||
expect(linkNode2.children[0].code).toBe('npm install')
|
||||
expect(linkNode2.children[1].type).toBe('text')
|
||||
expect(linkNode2.children[1].content).toBe(' command')
|
||||
})
|
||||
})
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getMarkdown, parseMarkdownToStructure } from '../src'
|
||||
|
||||
function collectTexts(n: any): string[] {
|
||||
if (!n)
|
||||
return []
|
||||
if (Array.isArray(n))
|
||||
return n.flatMap(collectTexts)
|
||||
if (typeof n === 'string')
|
||||
return [n]
|
||||
const texts: string[] = []
|
||||
if (n.type === 'text' && typeof n.content === 'string')
|
||||
texts.push(n.content)
|
||||
if (n.children && Array.isArray(n.children))
|
||||
texts.push(...n.children.flatMap(collectTexts))
|
||||
if (n.items && Array.isArray(n.items))
|
||||
texts.push(...n.items.flatMap(collectTexts))
|
||||
return texts
|
||||
}
|
||||
|
||||
function countOccurrences(nodes: any, needle: string) {
|
||||
const allTexts = collectTexts(nodes).join('\n')
|
||||
return (allTexts.match(new RegExp(needle, 'g')) || []).length
|
||||
}
|
||||
|
||||
describe('parseMarkdownToStructure - duplicate question rendering', () => {
|
||||
it('does not duplicate the question text when parsing mixed math/ce commands', () => {
|
||||
const md = getMarkdown()
|
||||
|
||||
const markdown = `**当堂检测**:
|
||||
1. 下列物质属于酚的是( )
|
||||
A. $\ce{CH3CH2OH}$ B. $\ce{C6H5CH2OH}$ C. $\ce{}$ D. $\ce{HO-CH2-CH2OH}$
|
||||
**答案**:C`
|
||||
|
||||
const nodes = parseMarkdownToStructure(markdown, md)
|
||||
const allTexts = collectTexts(nodes).join('\n')
|
||||
console.log('All texts:', JSON.stringify(allTexts, null, 2))
|
||||
// Count occurrences of the core question string
|
||||
expect(countOccurrences(nodes, '下列物质属于酚的是')).toBe(1)
|
||||
})
|
||||
|
||||
const streamingCases = [
|
||||
{
|
||||
name: 'plain text prefix before $$ math',
|
||||
markdown: `第(1)题
|
||||
题干解析:本题考查利用导数求含参函数的极值,核心条件为函数 $$ f(x) = \\ln x - m + \\dfrac{m}{x} $$($$ m \\in \\mathbb{R} $$)`,
|
||||
needle: '第(1)题',
|
||||
},
|
||||
{
|
||||
name: 'plain text prefix before $ math',
|
||||
markdown: `第(2)题
|
||||
已知函数 $f(x)=x^2$,求导数。`,
|
||||
needle: '第(2)题',
|
||||
},
|
||||
{
|
||||
name: 'plain text prefix before \\( \\) math',
|
||||
markdown: String.raw`第(3)题
|
||||
若 \(a+b=1\),求 \(ab\) 的值。`,
|
||||
needle: '第(3)题',
|
||||
},
|
||||
{
|
||||
name: 'strong prefix before $$ math',
|
||||
markdown: `**第(4)题**
|
||||
题干解析:函数 $$x^2$$ 的图像。`,
|
||||
needle: '第(4)题',
|
||||
},
|
||||
{
|
||||
name: 'markdown link prefix before $ math',
|
||||
markdown: `[第(5)题](https://example.com)
|
||||
题干解析:函数 $x^2$ 的图像。`,
|
||||
needle: '第(5)题',
|
||||
},
|
||||
{
|
||||
name: 'inline html prefix before $$ math',
|
||||
markdown: `<span>第(6)题</span>
|
||||
题干解析:函数 $$x^2$$ 的图像。`,
|
||||
needle: '第(6)题',
|
||||
},
|
||||
] as const
|
||||
|
||||
for (const testCase of streamingCases) {
|
||||
it(`does not duplicate the hardbreak prefix for ${testCase.name} while streaming`, () => {
|
||||
const md = getMarkdown()
|
||||
const nodes = parseMarkdownToStructure(testCase.markdown, md)
|
||||
|
||||
expect(countOccurrences(nodes, testCase.needle)).toBe(1)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
// Delegate to the repository-level vitest setup so package tests
|
||||
// reuse the same global mocks (Worker, ResizeObserver, etc.).
|
||||
import '../../../../test/setup/vitest.setup'
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseFenceToken } from '../src'
|
||||
|
||||
function parseStreamingDiff(content: string, closed = false) {
|
||||
return parseFenceToken({
|
||||
type: 'fence',
|
||||
info: 'diff json:package.json',
|
||||
content,
|
||||
map: [0, 1],
|
||||
meta: { closed },
|
||||
} as any)
|
||||
}
|
||||
|
||||
describe('parseFenceToken - streaming unified diff stability', () => {
|
||||
it('splits standard unified diff lines without requiring a space after the marker', () => {
|
||||
const node = parseFenceToken({
|
||||
type: 'fence',
|
||||
info: 'diff typescript',
|
||||
content: [
|
||||
'diff --git a/src/render.ts b/src/render.ts',
|
||||
'index 0000000..1111111 100644',
|
||||
'--- a/src/render.ts',
|
||||
'+++ b/src/render.ts',
|
||||
'@@ -1,7 +1,12 @@',
|
||||
'-export function render(input) {',
|
||||
'- return input',
|
||||
'+export function render(input: string) {',
|
||||
'+ if (!input.trim())',
|
||||
'+ return \'empty\'',
|
||||
'+',
|
||||
'+ const normalized = input.replace(/\\r\\n/g, \'\\n\')',
|
||||
'+ return normalized',
|
||||
' }',
|
||||
].join('\n'),
|
||||
map: [0, 1],
|
||||
meta: { closed: true },
|
||||
} as any)
|
||||
|
||||
expect(node.language).toBe('typescript')
|
||||
expect(node.originalCode).toBe([
|
||||
'export function render(input) {',
|
||||
' return input',
|
||||
'}',
|
||||
].join('\n'))
|
||||
expect(node.updatedCode).toBe([
|
||||
'export function render(input: string) {',
|
||||
' if (!input.trim())',
|
||||
' return \'empty\'',
|
||||
'',
|
||||
' const normalized = input.replace(/\\r\\n/g, \'\\n\')',
|
||||
' return normalized',
|
||||
'}',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
it('withholds an unfinished replacement hunk until the added side is available', () => {
|
||||
const node = parseStreamingDiff([
|
||||
'{',
|
||||
' "name": "markstream-vue",',
|
||||
'- "version": "0.0.49",',
|
||||
].join('\n'))
|
||||
|
||||
expect(node.originalCode).toBe([
|
||||
'{',
|
||||
' "name": "markstream-vue",',
|
||||
].join('\n'))
|
||||
expect(node.updatedCode).toBe([
|
||||
'{',
|
||||
' "name": "markstream-vue",',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
it('flushes a finished replacement hunk once the added line becomes stable', () => {
|
||||
const node = parseStreamingDiff([
|
||||
'{',
|
||||
' "name": "markstream-vue",',
|
||||
'- "version": "0.0.49",',
|
||||
'+ "version": "0.0.54-beta.1",',
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
expect(node.originalCode).toBe([
|
||||
'{',
|
||||
' "name": "markstream-vue",',
|
||||
' "version": "0.0.49",',
|
||||
].join('\n'))
|
||||
expect(node.updatedCode).toBe([
|
||||
'{',
|
||||
' "name": "markstream-vue",',
|
||||
' "version": "0.0.54-beta.1",',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
it('withholds a trailing incomplete added line instead of leaking a mismatched preview', () => {
|
||||
const node = parseStreamingDiff([
|
||||
'{',
|
||||
' "name": "markstream-vue",',
|
||||
'- "version": "0.0.49",',
|
||||
'+ "version": "0.0.54-b',
|
||||
].join('\n'))
|
||||
|
||||
expect(node.originalCode).toBe([
|
||||
'{',
|
||||
' "name": "markstream-vue",',
|
||||
].join('\n'))
|
||||
expect(node.updatedCode).toBe([
|
||||
'{',
|
||||
' "name": "markstream-vue",',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
it('flushes the trailing hunk once the fence is closed', () => {
|
||||
const node = parseStreamingDiff([
|
||||
'{',
|
||||
' "name": "markstream-vue",',
|
||||
'- "version": "0.0.49",',
|
||||
].join('\n'), true)
|
||||
|
||||
expect(node.originalCode).toBe([
|
||||
'{',
|
||||
' "name": "markstream-vue",',
|
||||
' "version": "0.0.49",',
|
||||
].join('\n'))
|
||||
expect(node.updatedCode).toBe([
|
||||
'{',
|
||||
' "name": "markstream-vue",',
|
||||
].join('\n'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseInlineTokens } from '../src'
|
||||
|
||||
function flattenText(nodes: any[]): string {
|
||||
const out: string[] = []
|
||||
const walk = (n: any) => {
|
||||
if (!n)
|
||||
return
|
||||
if (Array.isArray(n)) {
|
||||
n.forEach(walk)
|
||||
return
|
||||
}
|
||||
if (n.type === 'text' && typeof n.content === 'string')
|
||||
out.push(n.content)
|
||||
if (Array.isArray(n.children))
|
||||
n.children.forEach(walk)
|
||||
if (Array.isArray(n.items))
|
||||
n.items.forEach(walk)
|
||||
}
|
||||
walk(nodes)
|
||||
return out.join('')
|
||||
}
|
||||
|
||||
describe('parseInlineTokens strict strong matching', () => {
|
||||
it('default (non-strict) treats unclosed ** as mid-state strong', () => {
|
||||
const raw = '[**cxx](xxx)'
|
||||
const tokens: any[] = [{ type: 'text', content: raw }]
|
||||
const nodes = parseInlineTokens(tokens, raw)
|
||||
expect(nodes.length).toBeGreaterThan(0)
|
||||
// Expect the parser will create a strong node in non-strict mode
|
||||
const first = nodes[0] as any
|
||||
expect(first).toBeDefined()
|
||||
expect(first.type).toBe('strong')
|
||||
expect(first.children?.[0]?.type).toBe('link')
|
||||
expect(first.children?.[0]?.text).toContain('cxx')
|
||||
})
|
||||
|
||||
it('strict mode keeps original text when no closing ** is found', () => {
|
||||
const raw = '[**cxx](xxx)'
|
||||
const tokens: any[] = [{ type: 'text', content: raw }]
|
||||
const nodes = parseInlineTokens(tokens, raw, undefined, { requireClosingStrong: true })
|
||||
expect(nodes.length).toBeGreaterThan(0)
|
||||
const first = nodes[0] as any
|
||||
expect(first.type).toBe('link')
|
||||
expect(first.children[0].content).toBe(raw.match(/\[([^\]]+)\]/)?.[1])
|
||||
})
|
||||
|
||||
it('strict mode does not duplicate prefix text before unmatched **', () => {
|
||||
const raw = 'foo**bar'
|
||||
const tokens: any[] = [{ type: 'text', content: raw }]
|
||||
const nodes = parseInlineTokens(tokens, raw, undefined, { requireClosingStrong: true })
|
||||
expect(nodes.length).toBeGreaterThan(0)
|
||||
const first = nodes[0] as any
|
||||
expect(first.type).toBe('text')
|
||||
expect(first.content).toBe('foo**bar')
|
||||
})
|
||||
|
||||
it('strict mode does not duplicate prefix when unmatched ** is inside link text', () => {
|
||||
const raw = '前缀[**cxx](xxx)'
|
||||
const tokens: any[] = [{ type: 'text', content: raw }]
|
||||
const nodes = parseInlineTokens(tokens, raw, undefined, { requireClosingStrong: true })
|
||||
expect(flattenText(nodes as any[])).toBe('前缀**cxx')
|
||||
})
|
||||
})
|
||||
+378
@@ -0,0 +1,378 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getMarkdown, parseMarkdownToStructure } from '../src'
|
||||
|
||||
function expectLinkChild(node: any, expected: {
|
||||
href: string
|
||||
text: string
|
||||
loading: boolean
|
||||
childType: string
|
||||
childText: string
|
||||
}) {
|
||||
expect(node.type).toBe('link')
|
||||
expect(node.href).toBe(expected.href)
|
||||
expect(node.text).toBe(expected.text)
|
||||
expect(node.loading).toBe(expected.loading)
|
||||
expect(node.attrs).toEqual(expect.arrayContaining([['href', expected.href]]))
|
||||
expect(node.children).toHaveLength(1)
|
||||
expect(node.children[0].type).toBe(expected.childType)
|
||||
expect(node.children[0].raw).toBe(expected.text)
|
||||
expect(node.children[0].children).toHaveLength(1)
|
||||
expect(node.children[0].children[0].type).toBe('text')
|
||||
expect(node.children[0].children[0].content).toBe(expected.childText)
|
||||
}
|
||||
|
||||
describe('strong link followed by inline content', () => {
|
||||
const md = getMarkdown('strong-link-followed-by-inline-content')
|
||||
|
||||
it('keeps trailing text and the next italic link outside the preceding strong link', () => {
|
||||
const markdown = `[**加粗的链接**](https://example.com)会导致后面的斜体链接解析不正确
|
||||
[*斜体的链接*](https://test.com)`
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
|
||||
|
||||
expect(nodes).toHaveLength(1)
|
||||
const paragraph = nodes[0]
|
||||
expect(paragraph.type).toBe('paragraph')
|
||||
expect(paragraph.children).toHaveLength(3)
|
||||
|
||||
expectLinkChild(paragraph.children[0], {
|
||||
href: 'https://example.com',
|
||||
text: '**加粗的链接**',
|
||||
loading: false,
|
||||
childType: 'strong',
|
||||
childText: '加粗的链接',
|
||||
})
|
||||
|
||||
expect(paragraph.children[1]).toEqual(expect.objectContaining({
|
||||
type: 'text',
|
||||
content: '会导致后面的斜体链接解析不正确\n',
|
||||
raw: '会导致后面的斜体链接解析不正确\n',
|
||||
}))
|
||||
|
||||
expectLinkChild(paragraph.children[2], {
|
||||
href: 'https://test.com',
|
||||
text: '*斜体的链接*',
|
||||
loading: false,
|
||||
childType: 'emphasis',
|
||||
childText: '斜体的链接',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps two inline links separated by the plain text between them', () => {
|
||||
const markdown = `[**加粗的链接**](https://example.com) 或者 [*斜体的链接*](https://test.com)`
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
|
||||
|
||||
expect(nodes).toHaveLength(1)
|
||||
const paragraph = nodes[0]
|
||||
expect(paragraph.type).toBe('paragraph')
|
||||
expect(paragraph.children).toHaveLength(3)
|
||||
|
||||
expectLinkChild(paragraph.children[0], {
|
||||
href: 'https://example.com',
|
||||
text: '**加粗的链接**',
|
||||
loading: false,
|
||||
childType: 'strong',
|
||||
childText: '加粗的链接',
|
||||
})
|
||||
|
||||
expect(paragraph.children[1]).toEqual(expect.objectContaining({
|
||||
type: 'text',
|
||||
content: ' 或者 ',
|
||||
raw: ' 或者 ',
|
||||
}))
|
||||
|
||||
expectLinkChild(paragraph.children[2], {
|
||||
href: 'https://test.com',
|
||||
text: '*斜体的链接*',
|
||||
loading: false,
|
||||
childType: 'emphasis',
|
||||
childText: '斜体的链接',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps trailing punctuation and explanatory text outside the strong link', () => {
|
||||
const markdown = `[**加粗的链接**](https://example.com),这里不是链接部分,但是有链接效果`
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
|
||||
|
||||
expect(nodes).toHaveLength(1)
|
||||
const paragraph = nodes[0]
|
||||
expect(paragraph.type).toBe('paragraph')
|
||||
expect(paragraph.children).toHaveLength(2)
|
||||
|
||||
expectLinkChild(paragraph.children[0], {
|
||||
href: 'https://example.com',
|
||||
text: '**加粗的链接**',
|
||||
loading: false,
|
||||
childType: 'strong',
|
||||
childText: '加粗的链接',
|
||||
})
|
||||
|
||||
expect(paragraph.children[1]).toEqual(expect.objectContaining({
|
||||
type: 'text',
|
||||
content: ',这里不是链接部分,但是有链接效果',
|
||||
raw: ',这里不是链接部分,但是有链接效果',
|
||||
}))
|
||||
})
|
||||
|
||||
it('keeps immediate trailing text outside a strong link even without whitespace', () => {
|
||||
const markdown = `[**加粗的链接**](https://example.com)tail`
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
|
||||
|
||||
expect(nodes).toHaveLength(1)
|
||||
const paragraph = nodes[0]
|
||||
expect(paragraph.type).toBe('paragraph')
|
||||
expect(paragraph.children).toHaveLength(2)
|
||||
|
||||
expectLinkChild(paragraph.children[0], {
|
||||
href: 'https://example.com',
|
||||
text: '**加粗的链接**',
|
||||
loading: false,
|
||||
childType: 'strong',
|
||||
childText: '加粗的链接',
|
||||
})
|
||||
|
||||
expect(paragraph.children[1]).toEqual(expect.objectContaining({
|
||||
type: 'text',
|
||||
content: 'tail',
|
||||
raw: 'tail',
|
||||
}))
|
||||
})
|
||||
|
||||
it('keeps completed strong and italic links stable in non-final parsing mode', () => {
|
||||
const markdown = `[**加粗的链接**](https://example.com) 或者 [*斜体的链接*](https://test.com)`
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: false }) as any[]
|
||||
|
||||
expect(nodes).toHaveLength(1)
|
||||
const paragraph = nodes[0]
|
||||
expect(paragraph.type).toBe('paragraph')
|
||||
expect(paragraph.children).toHaveLength(3)
|
||||
|
||||
expectLinkChild(paragraph.children[0], {
|
||||
href: 'https://example.com',
|
||||
text: '**加粗的链接**',
|
||||
loading: false,
|
||||
childType: 'strong',
|
||||
childText: '加粗的链接',
|
||||
})
|
||||
|
||||
expect(paragraph.children[1]).toEqual(expect.objectContaining({
|
||||
type: 'text',
|
||||
content: ' 或者 ',
|
||||
raw: ' 或者 ',
|
||||
}))
|
||||
|
||||
expectLinkChild(paragraph.children[2], {
|
||||
href: 'https://test.com',
|
||||
text: '*斜体的链接*',
|
||||
loading: false,
|
||||
childType: 'emphasis',
|
||||
childText: '斜体的链接',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps strong links with inline code labels separated from trailing text', () => {
|
||||
const markdown = `[**用 \`pnpm\` 安装**](https://example.com)tail`
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
|
||||
|
||||
expect(nodes).toHaveLength(1)
|
||||
const paragraph = nodes[0]
|
||||
expect(paragraph.type).toBe('paragraph')
|
||||
expect(paragraph.children).toHaveLength(2)
|
||||
|
||||
const link = paragraph.children[0]
|
||||
expect(link.type).toBe('link')
|
||||
expect(link.href).toBe('https://example.com')
|
||||
expect(link.text).toBe('**用 pnpm 安装**')
|
||||
expect(link.loading).toBe(false)
|
||||
expect(link.children).toHaveLength(1)
|
||||
expect(link.children[0].type).toBe('strong')
|
||||
expect(link.children[0].children).toHaveLength(3)
|
||||
expect(link.children[0].children[0]).toEqual(expect.objectContaining({
|
||||
type: 'text',
|
||||
content: '用 ',
|
||||
}))
|
||||
expect(link.children[0].children[1]).toEqual(expect.objectContaining({
|
||||
type: 'inline_code',
|
||||
code: 'pnpm',
|
||||
}))
|
||||
expect(link.children[0].children[2]).toEqual(expect.objectContaining({
|
||||
type: 'text',
|
||||
content: ' 安装',
|
||||
}))
|
||||
|
||||
expect(paragraph.children[1]).toEqual(expect.objectContaining({
|
||||
type: 'text',
|
||||
content: 'tail',
|
||||
raw: 'tail',
|
||||
}))
|
||||
})
|
||||
|
||||
it('preserves title attrs on strong links while keeping trailing text outside the link', () => {
|
||||
const markdown = `[**x**](https://a.com "title")tail`
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
|
||||
|
||||
expect(nodes).toHaveLength(1)
|
||||
const paragraph = nodes[0]
|
||||
expect(paragraph.type).toBe('paragraph')
|
||||
expect(paragraph.children).toHaveLength(2)
|
||||
|
||||
const link = paragraph.children[0]
|
||||
expect(link.type).toBe('link')
|
||||
expect(link.href).toBe('https://a.com')
|
||||
expect(link.title).toBe('title')
|
||||
expect(link.text).toBe('**x**')
|
||||
expect(link.loading).toBe(false)
|
||||
expect(link.attrs).toEqual(expect.arrayContaining([
|
||||
['href', 'https://a.com'],
|
||||
['title', 'title'],
|
||||
]))
|
||||
|
||||
expect(paragraph.children[1]).toEqual(expect.objectContaining({
|
||||
type: 'text',
|
||||
content: 'tail',
|
||||
raw: 'tail',
|
||||
}))
|
||||
})
|
||||
|
||||
it('keeps adjacent strong-label and code-label links independent', () => {
|
||||
const markdown = `[**x**](https://a.com)[\`y\`](https://b.com)`
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
|
||||
|
||||
expect(nodes).toHaveLength(1)
|
||||
const paragraph = nodes[0]
|
||||
expect(paragraph.type).toBe('paragraph')
|
||||
expect(paragraph.children).toHaveLength(2)
|
||||
|
||||
expectLinkChild(paragraph.children[0], {
|
||||
href: 'https://a.com',
|
||||
text: '**x**',
|
||||
loading: false,
|
||||
childType: 'strong',
|
||||
childText: 'x',
|
||||
})
|
||||
|
||||
const codeLink = paragraph.children[1]
|
||||
expect(codeLink.type).toBe('link')
|
||||
expect(codeLink.href).toBe('https://b.com')
|
||||
expect(codeLink.text).toBe('y')
|
||||
expect(codeLink.loading).toBe(false)
|
||||
expect(codeLink.children).toHaveLength(1)
|
||||
expect(codeLink.children[0]).toEqual(expect.objectContaining({
|
||||
type: 'inline_code',
|
||||
code: 'y',
|
||||
raw: 'y',
|
||||
}))
|
||||
})
|
||||
|
||||
it('marks an unfinished strong link URL as loading in non-final mode', () => {
|
||||
const markdown = `[**x**](https://a.com`
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: false }) as any[]
|
||||
|
||||
expect(nodes).toHaveLength(1)
|
||||
const paragraph = nodes[0]
|
||||
expect(paragraph.type).toBe('paragraph')
|
||||
expect(paragraph.children).toHaveLength(1)
|
||||
|
||||
const strong = paragraph.children[0]
|
||||
expect(strong.type).toBe('strong')
|
||||
expect(strong.children).toHaveLength(1)
|
||||
|
||||
const link = strong.children[0]
|
||||
expect(link.type).toBe('link')
|
||||
expect(link.loading).toBe(true)
|
||||
expect(link.text).toBe('x')
|
||||
expect(link.children).toHaveLength(1)
|
||||
expect(link.children[0]).toEqual(expect.objectContaining({
|
||||
type: 'text',
|
||||
content: 'x',
|
||||
raw: 'x',
|
||||
}))
|
||||
})
|
||||
|
||||
it('keeps triple-emphasis labels separated from trailing text', () => {
|
||||
const markdown = `[***x***](https://a.com)tail`
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
|
||||
|
||||
expect(nodes).toHaveLength(1)
|
||||
const paragraph = nodes[0]
|
||||
expect(paragraph.type).toBe('paragraph')
|
||||
expect(paragraph.children).toHaveLength(2)
|
||||
|
||||
const link = paragraph.children[0]
|
||||
expect(link.type).toBe('link')
|
||||
expect(link.href).toBe('https://a.com')
|
||||
expect(link.text).toBe('*x*')
|
||||
expect(link.loading).toBe(false)
|
||||
expect(link.children).toHaveLength(1)
|
||||
expect(link.children[0].type).toBe('emphasis')
|
||||
expect(link.children[0].children).toHaveLength(1)
|
||||
expect(link.children[0].children[0].type).toBe('strong')
|
||||
expect(link.children[0].children[0].children[0]).toEqual(expect.objectContaining({
|
||||
type: 'text',
|
||||
content: 'x',
|
||||
}))
|
||||
|
||||
expect(paragraph.children[1]).toEqual(expect.objectContaining({
|
||||
type: 'text',
|
||||
content: 'tail',
|
||||
raw: 'tail',
|
||||
}))
|
||||
})
|
||||
|
||||
it('keeps html inline labels separated from trailing text', () => {
|
||||
const markdown = `[<span>x</span>](https://a.com)tail`
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
|
||||
|
||||
expect(nodes).toHaveLength(1)
|
||||
const paragraph = nodes[0]
|
||||
expect(paragraph.type).toBe('paragraph')
|
||||
expect(paragraph.children).toHaveLength(2)
|
||||
|
||||
const link = paragraph.children[0]
|
||||
expect(link.type).toBe('link')
|
||||
expect(link.href).toBe('https://a.com')
|
||||
expect(link.text).toBe('<span>x</span>')
|
||||
expect(link.loading).toBe(false)
|
||||
expect(link.children).toHaveLength(1)
|
||||
expect(link.children[0]).toEqual(expect.objectContaining({
|
||||
type: 'html_inline',
|
||||
tag: 'span',
|
||||
content: '<span>x</span>',
|
||||
loading: false,
|
||||
}))
|
||||
expect(link.children[0].children).toHaveLength(1)
|
||||
expect(link.children[0].children[0]).toEqual(expect.objectContaining({
|
||||
type: 'text',
|
||||
content: 'x',
|
||||
}))
|
||||
|
||||
expect(paragraph.children[1]).toEqual(expect.objectContaining({
|
||||
type: 'text',
|
||||
content: 'tail',
|
||||
raw: 'tail',
|
||||
}))
|
||||
})
|
||||
|
||||
it('keeps punctuation after a strong link outside the link node', () => {
|
||||
const markdown = `[**x**](https://a.com)!tail`
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
|
||||
|
||||
expect(nodes).toHaveLength(1)
|
||||
const paragraph = nodes[0]
|
||||
expect(paragraph.type).toBe('paragraph')
|
||||
expect(paragraph.children).toHaveLength(2)
|
||||
|
||||
expectLinkChild(paragraph.children[0], {
|
||||
href: 'https://a.com',
|
||||
text: '**x**',
|
||||
loading: false,
|
||||
childType: 'strong',
|
||||
childText: 'x',
|
||||
})
|
||||
|
||||
expect(paragraph.children[1]).toEqual(expect.objectContaining({
|
||||
type: 'text',
|
||||
content: '!tail',
|
||||
raw: '!tail',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getMarkdown, parseMarkdownToStructure } from '../src'
|
||||
|
||||
describe('strong containing link edge-case', () => {
|
||||
it('parses **[百度](https://baidu.com/)** as strong -> link -> text', () => {
|
||||
const md = getMarkdown()
|
||||
const label = '\u767E\u5EA6'
|
||||
const markdown = `**[${label}](https://baidu.com/)*`
|
||||
|
||||
const nodes = parseMarkdownToStructure(markdown, md)
|
||||
// top-level should be a paragraph
|
||||
const para = nodes[0] as any
|
||||
expect(para.type).toBe('paragraph')
|
||||
|
||||
const strong = para.children?.[0]
|
||||
expect(strong).toBeDefined()
|
||||
expect(strong.type).toBe('strong')
|
||||
|
||||
const link = strong.children?.[0]
|
||||
expect(link).toBeDefined()
|
||||
expect(link.type).toBe('link')
|
||||
// markdown-it may normalize or ensure trailing slash; accept main host
|
||||
expect(link.href).toMatch(/https:\/\/baidu\.com\/?/)
|
||||
|
||||
const text = link.children?.[0]
|
||||
expect(text).toBeDefined()
|
||||
expect(text.type).toBe('text')
|
||||
expect(text.content).toBe(label)
|
||||
})
|
||||
|
||||
it('parses **[DR **(Danmarks Radio)](https://baidu.com/)** as strong -> link -> text', () => {
|
||||
const md = getMarkdown()
|
||||
const label = 'DR **(Danmarks Radio)'
|
||||
const markdown = `**[${label}](https://baidu.com/)*`
|
||||
|
||||
const nodes = parseMarkdownToStructure(markdown, md)
|
||||
// top-level should be a paragraph
|
||||
const para = nodes[0] as any
|
||||
expect(para.type).toBe('paragraph')
|
||||
|
||||
const strong = para.children?.[0]
|
||||
expect(strong).toBeDefined()
|
||||
expect(strong.type).toBe('strong')
|
||||
|
||||
const link = strong.children?.[0]
|
||||
expect(link).toBeDefined()
|
||||
expect(link.type).toBe('link')
|
||||
// markdown-it may normalize or ensure trailing slash; accept main host
|
||||
expect(link.href).toMatch(/https:\/\/baidu\.com\/?/)
|
||||
|
||||
const text = link.children?.[0]
|
||||
const strongText = link.children?.[1]
|
||||
expect(strongText).toBeDefined()
|
||||
expect(strongText.type).toBe('strong')
|
||||
expect(strongText.children?.[0].content).toBe('(Danmarks Radio)')
|
||||
expect(text).toBeDefined()
|
||||
expect(text.type).toBe('text')
|
||||
expect(text.content).toBe('DR ')
|
||||
})
|
||||
|
||||
it('parses **[DR (Danmarks Radio)**](https://baidu.com/)** as strong -> link -> text', () => {
|
||||
const md = getMarkdown()
|
||||
const label = 'DR (Danmarks Radio)**'
|
||||
const markdown = `**[${label}](https://baidu.com/)*`
|
||||
|
||||
const nodes = parseMarkdownToStructure(markdown, md)
|
||||
// top-level should be a paragraph
|
||||
const para = nodes[0] as any
|
||||
expect(para.type).toBe('paragraph')
|
||||
|
||||
const strong = para.children?.[0]
|
||||
expect(strong).toBeDefined()
|
||||
expect(strong.type).toBe('strong')
|
||||
|
||||
const link = strong.children?.[0]
|
||||
expect(link).toBeDefined()
|
||||
expect(link.type).toBe('link')
|
||||
// markdown-it may normalize or ensure trailing slash; accept main host
|
||||
expect(link.href).toMatch(/https:\/\/baidu\.com\/?/)
|
||||
|
||||
const text = link.children?.[0]
|
||||
expect(text).toBeDefined()
|
||||
expect(text.type).toBe('text')
|
||||
expect(text.content).toBe(label.slice(0, -2))
|
||||
})
|
||||
|
||||
it('parses **[**(Danmarks Radio)**](https://baidu.com/)** as strong -> link -> text', () => {
|
||||
const md = getMarkdown()
|
||||
const label = '**(Danmarks Radio)**'
|
||||
const markdown = `**[${label}](https://baidu.com/)*`
|
||||
|
||||
const nodes = parseMarkdownToStructure(markdown, md)
|
||||
// top-level should be a paragraph
|
||||
const para = nodes[0] as any
|
||||
expect(para.type).toBe('paragraph')
|
||||
|
||||
const strong = para.children?.[0]
|
||||
expect(strong).toBeDefined()
|
||||
expect(strong.type).toBe('strong')
|
||||
|
||||
const link = strong.children?.[0]
|
||||
expect(link).toBeDefined()
|
||||
expect(link.type).toBe('link')
|
||||
// markdown-it may normalize or ensure trailing slash; accept main host
|
||||
expect(link.href).toMatch(/https:\/\/baidu\.com\/?/)
|
||||
|
||||
const text = link.children?.[0]
|
||||
expect(text).toBeDefined()
|
||||
expect(text.type).toBe('strong')
|
||||
expect(text.children?.[0].content).toBe(label.slice(2, -2))
|
||||
})
|
||||
})
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getMarkdown, parseMarkdownToStructure } from '../src'
|
||||
|
||||
describe('parseMarkdownToStructure - streaming strong+math', () => {
|
||||
it('does not hang on unclosed strong ending with a closed $...$ span', { timeout: 500 }, () => {
|
||||
const md = getMarkdown('stream-strong-math')
|
||||
const markdown = '> **给定输入 $ x $'
|
||||
|
||||
expect(() => parseMarkdownToStructure(markdown, md)).not.toThrow()
|
||||
|
||||
// Simulate streaming: repeatedly parse progressive prefixes.
|
||||
for (let i = 1; i <= markdown.length; i++) {
|
||||
const chunk = markdown.slice(0, i)
|
||||
expect(() => parseMarkdownToStructure(chunk, md)).not.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not recurse forever when a streaming strong prefix ends right before math opener content', { timeout: 500 }, () => {
|
||||
const md = getMarkdown('stream-strong-heading-math')
|
||||
const markdown = '### 1. **理解 \\\\(\\\\boldsymbol{alpha}^T \\\\boldsymbol{\\\\beta} = 0\\\\) 的含义**'
|
||||
|
||||
for (let i = 1; i <= markdown.length; i++) {
|
||||
const chunk = markdown.slice(0, i)
|
||||
expect(() => parseMarkdownToStructure(chunk, md, { final: false })).not.toThrow()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getMarkdown, parseMarkdownToStructure } from '../src'
|
||||
|
||||
describe('table <br> handling', () => {
|
||||
it('converts <br> inside table cells into hardbreak nodes', () => {
|
||||
const md = getMarkdown()
|
||||
const markdown = `| 环节 | 优等生任务 | 学困生任务 | 时间 | 准备材料 |
|
||||
|--------|------------------|-----------------|-------|-------------------|
|
||||
| **自学** | 1. 用成语形容小蝌蚪(例:锲而不舍)<br>2. 画“成长流程图” | 1. 圈出文中动物名称<br>2. 跟读课文录音 | 8分钟 | 课文、彩笔 |
|
||||
| **助学** | 1. 设计“特征谜语”考同伴<br>2. 分析乌龟为何不是妈妈 | 1. 用特征卡片配对动物<br>2. 填空补全对话 | 12分钟 | 动物卡片、对话条 |
|
||||
| **共学** | 1. 领读青蛙妈妈台词(带动作)<br>2. 总结“坚持的道理” | 1. 模仿角色语气朗读<br>2. 复述变化顺序 | 15分钟 | 头饰、奖励贴纸 |`
|
||||
|
||||
const nodes = parseMarkdownToStructure(markdown, md)
|
||||
// find the table node
|
||||
const table = nodes.find(n => n.type === 'table') as any
|
||||
expect(table).toBeDefined()
|
||||
// first data row (after header) should exist
|
||||
expect(table.rows.length).toBeGreaterThan(0)
|
||||
|
||||
const firstRow = table.rows[0]
|
||||
// second cell (优等生任务) should contain a hardbreak node
|
||||
const targetCell = firstRow.cells[1]
|
||||
expect(targetCell).toBeDefined()
|
||||
const hasHardbreak = Array.isArray(targetCell.children) && targetCell.children.some((c: any) => c.type === 'hardbreak')
|
||||
expect(hasHardbreak).toBe(true)
|
||||
})
|
||||
})
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getMarkdown, parseMarkdownToStructure } from '../src'
|
||||
import { fixTableTokens } from '../src/plugins/fixTableTokens'
|
||||
|
||||
describe('table loading mid-states', () => {
|
||||
it('recognizes spaced separator rows as loading tables', () => {
|
||||
const md = getMarkdown('table-loading-spaced-separator')
|
||||
const markdown = `# 表格
|
||||
|列1|列2|列3|列4|列5|
|
||||
| - | - | - | - | `
|
||||
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: false }) as any[]
|
||||
const table = nodes.find(node => node.type === 'table') as any
|
||||
|
||||
expect(table).toBeTruthy()
|
||||
expect(table.loading).toBe(true)
|
||||
expect(table.header.cells.map((cell: any) => cell.raw)).toEqual(['列1', '列2', '列3', '列4', '列5'])
|
||||
})
|
||||
|
||||
it('keeps header cells when the separator row is only partially typed', () => {
|
||||
const md = getMarkdown('table-loading-partial-separator')
|
||||
const markdown = `# 表格
|
||||
|列1|列2|列3|列4|列5|
|
||||
|:-`
|
||||
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: false }) as any[]
|
||||
const table = nodes.find(node => node.type === 'table') as any
|
||||
|
||||
expect(table).toBeTruthy()
|
||||
expect(table.loading).toBe(true)
|
||||
expect(table.header.cells.map((cell: any) => cell.raw)).toEqual(['列1', '列2', '列3', '列4', '列5'])
|
||||
})
|
||||
|
||||
it('fixes inline fallback tables with spaced alignment markers', () => {
|
||||
const tokens = [
|
||||
{ type: 'paragraph_open', tag: 'p' },
|
||||
{ type: 'inline', tag: '', content: '|列1|列2|列3|\n| :- | :-: | -: |', children: null },
|
||||
{ type: 'paragraph_close', tag: 'p' },
|
||||
] as any[]
|
||||
|
||||
const fixed = fixTableTokens(tokens)
|
||||
|
||||
expect(fixed[0]?.type).toBe('table_open')
|
||||
expect(fixed[0]?.loading).toBe(true)
|
||||
expect(fixed.filter(token => token.type === 'th_open')).toHaveLength(3)
|
||||
expect(fixed.filter(token => token.type === 'inline').map(token => token.content)).toEqual(['列1', '列2', '列3'])
|
||||
})
|
||||
|
||||
it('does not hang on long malformed separator rows', { timeout: 500 }, () => {
|
||||
const tokens = [
|
||||
{ type: 'paragraph_open', tag: 'p' },
|
||||
{
|
||||
type: 'inline',
|
||||
tag: '',
|
||||
content: `|${'列|'.repeat(4000)}\n|${' :-'.repeat(4000)}`,
|
||||
children: [{}, {}, {}],
|
||||
},
|
||||
{ type: 'paragraph_close', tag: 'p' },
|
||||
] as any[]
|
||||
|
||||
const fixed = fixTableTokens(tokens)
|
||||
|
||||
expect(fixed).toHaveLength(3)
|
||||
expect(fixed[1]?.type).toBe('inline')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getMarkdown, parseMarkdownToStructure } from '../src'
|
||||
|
||||
describe('inline `text_special` tokens', () => {
|
||||
it('treats escaped punctuation as plain text', () => {
|
||||
const md = getMarkdown('text_special')
|
||||
const markdown = 'Before \\[brackets\\] and \\(parens\\).'
|
||||
|
||||
const nodes = parseMarkdownToStructure(markdown, md)
|
||||
expect(nodes[0]?.type).toBe('paragraph')
|
||||
|
||||
const children = (nodes[0] as any).children as any[]
|
||||
expect(children.map(n => n.type)).not.toContain('text_special')
|
||||
|
||||
const text = children
|
||||
.filter(n => n?.type === 'text')
|
||||
.map(n => String(n.content ?? ''))
|
||||
.join('')
|
||||
|
||||
expect(text).toBe('Before [brackets] and (parens).')
|
||||
})
|
||||
|
||||
it('preserves a visible backslash before escaped punctuation', () => {
|
||||
const md = getMarkdown('text_special-visible-backslash')
|
||||
const markdown = 'Before \\\\[brackets\\] and \\\\*.'
|
||||
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
|
||||
expect(nodes[0]?.type).toBe('paragraph')
|
||||
|
||||
const children = (nodes[0] as any).children as any[]
|
||||
const text = children
|
||||
.filter(n => n?.type === 'text')
|
||||
.map(n => String(n.content ?? ''))
|
||||
.join('')
|
||||
|
||||
expect(text).toBe('Before \\[brackets] and \\*.')
|
||||
})
|
||||
})
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getMarkdown, parseMarkdownToStructure } from '../src'
|
||||
|
||||
describe('parseMarkdownToStructure - <think> regression', () => {
|
||||
it('does not hang on <think> blocks with lots of backslashes', () => {
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const markdown = readFileSync(join(here, 'fixtures/think-hang.md'), 'utf8')
|
||||
const md = getMarkdown('think-regression', { customHtmlTags: ['think'] })
|
||||
|
||||
expect(() => {
|
||||
// The hang used to occur inside `md.parse()` (math plugin) with a `$`
|
||||
// immediately following a `]` like `[M–H]$^–$`.
|
||||
md.parse(markdown, {})
|
||||
parseMarkdownToStructure(markdown, md, { customHtmlTags: ['think'] })
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getMarkdown, parseMarkdownToStructure } from '../src'
|
||||
|
||||
describe('thinking custom tag after inline html prefix', () => {
|
||||
const thinkingContent = String.raw`勾股定理是数学中一个非常重要的定理,尤其在几何学中有着广泛的应用。它描述了直角三角形三边之间的关系。
|
||||
|
||||
### 勾股定理的定义:
|
||||
在**直角三角形**中,斜边(即与直角相对的边)的平方等于另外两条直角边的平方和。
|
||||
|
||||
用公式表示为:
|
||||
|
||||
$$
|
||||
a^2 + b^2 = c^2
|
||||
$$
|
||||
|
||||
其中:
|
||||
- $ a $ 和 $ b $ 是直角三角形的两条直角边;
|
||||
- $ c $ 是斜边(即最长的一条边)。
|
||||
|
||||
---
|
||||
|
||||
### 举例说明:
|
||||
比如一个直角三角形,两条直角边分别是3和4,那么斜边 $ c $ 可以通过勾股定理计算得出:
|
||||
|
||||
$$
|
||||
c = \sqrt{3^2 + 4^2} = \sqrt{9 + 16} = \sqrt{25} = 5
|
||||
$$
|
||||
|
||||
所以这个三角形的三边是3、4、5,是一个经典的勾股数(毕达哥拉斯三元组)。
|
||||
|
||||
|
||||
如果你有兴趣,我还可以为你提供勾股定理的几种不同证明方式,或者讲解一些有趣的勾股数例子!`
|
||||
|
||||
it('keeps the full thinking markdown inside the thinking node and does not leak trailing block nodes', () => {
|
||||
const markdown = `<usrhead></usrhead><br>介绍下勾股定理<br><br><br><br><aihead></aihead><thinking>${thinkingContent}</thinking>`
|
||||
const tags = ['usrhead', 'aihead', 'thinking']
|
||||
const md = getMarkdown('thinking-inline-prefix-regression', { customHtmlTags: tags })
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { customHtmlTags: tags, final: true }) as any[]
|
||||
|
||||
expect(nodes.map(node => node?.type)).toEqual(['paragraph', 'thinking'])
|
||||
|
||||
const prefix = nodes[0]
|
||||
expect(prefix?.type).toBe('paragraph')
|
||||
expect(prefix?.children?.map((node: any) => node?.type)).toEqual([
|
||||
'usrhead',
|
||||
'hardbreak',
|
||||
'text',
|
||||
'hardbreak',
|
||||
'hardbreak',
|
||||
'hardbreak',
|
||||
'hardbreak',
|
||||
'aihead',
|
||||
])
|
||||
expect(prefix?.children?.[2]?.content).toBe('介绍下勾股定理')
|
||||
|
||||
const thinking = nodes[1]
|
||||
expect(thinking?.type).toBe('thinking')
|
||||
expect(thinking?.content).toBe(thinkingContent)
|
||||
expect(String(thinking?.raw ?? '')).toContain('<thinking>')
|
||||
expect(String(thinking?.raw ?? '')).toContain('</thinking>')
|
||||
expect(nodes.at(-1)?.type).toBe('thinking')
|
||||
})
|
||||
|
||||
it('keeps streaming block markers inside thinking before </thinking> arrives', () => {
|
||||
const markdown = `<usrhead></usrhead><br>介绍下勾股定理<br><br><br><br><aihead></aihead><thinking>${thinkingContent}`
|
||||
const tags = ['usrhead', 'aihead', 'thinking']
|
||||
const md = getMarkdown('thinking-inline-prefix-streaming-regression', { customHtmlTags: tags })
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { customHtmlTags: tags, final: false }) as any[]
|
||||
|
||||
expect(nodes.map(node => node?.type)).toEqual(['paragraph', 'thinking'])
|
||||
|
||||
const thinking = nodes[1]
|
||||
expect(thinking?.type).toBe('thinking')
|
||||
expect(thinking?.loading).toBe(true)
|
||||
expect(String(thinking?.content ?? '')).toContain('### 勾股定理的定义:')
|
||||
expect(String(thinking?.content ?? '')).toContain('---')
|
||||
expect(String(thinking?.content ?? '')).toContain('$$\nc = \\sqrt{3^2 + 4^2} = \\sqrt{9 + 16} = \\sqrt{25} = 5\n$$')
|
||||
expect(nodes.at(-1)?.type).toBe('thinking')
|
||||
|
||||
const innerNodes = parseMarkdownToStructure(String(thinking?.content ?? ''), getMarkdown('thinking-inline-prefix-streaming-inner'))
|
||||
expect(innerNodes.some(node => node?.type === 'heading')).toBe(true)
|
||||
expect(innerNodes.some(node => node?.type === 'thematic_break')).toBe(true)
|
||||
expect(innerNodes.some(node => node?.type === 'math_block')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a stable top-level thinking node from the first streaming chunk when the prefix contains <br>', () => {
|
||||
const tags = ['usrhead', 'aihead', 'thinking']
|
||||
const md = getMarkdown('thinking-inline-prefix-streaming-stability', { customHtmlTags: tags })
|
||||
const chunks = [
|
||||
'<usrhead></usrhead><br>介绍下勾股定理<br><br><br><br><aihead></aihead><thinking>第',
|
||||
'<usrhead></usrhead><br>介绍下勾股定理<br><br><br><br><aihead></aihead><thinking>第一段',
|
||||
'<usrhead></usrhead><br>介绍下勾股定理<br><br><br><br><aihead></aihead><thinking>第一段\n\n### 标题',
|
||||
]
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const nodes = parseMarkdownToStructure(chunk, md, { customHtmlTags: tags, final: false }) as any[]
|
||||
expect(nodes.map(node => node?.type)).toEqual(['paragraph', 'thinking'])
|
||||
expect(nodes[0]?.children?.some((node: any) => node?.type === 'thinking')).toBe(false)
|
||||
expect(nodes[1]?.type).toBe('thinking')
|
||||
expect(nodes[1]?.loading).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a stable top-level thinking node when the prefix is only <br> and the close arrives on the same line', () => {
|
||||
const md = getMarkdown('thinking-br-prefix-same-line-stability', { customHtmlTags: ['thinking'] })
|
||||
|
||||
const openNodes = parseMarkdownToStructure('<br><thinking>hi', md, { customHtmlTags: ['thinking'], final: false }) as any[]
|
||||
expect(openNodes.map(node => node?.type)).toEqual(['paragraph', 'thinking'])
|
||||
expect(openNodes[0]?.children?.map((node: any) => node?.type)).toEqual(['hardbreak'])
|
||||
expect(openNodes[1]?.type).toBe('thinking')
|
||||
expect(openNodes[1]?.loading).toBe(true)
|
||||
|
||||
const closedNodes = parseMarkdownToStructure('<br><thinking>hi</thinking>', md, { customHtmlTags: ['thinking'], final: true }) as any[]
|
||||
expect(closedNodes.map(node => node?.type)).toEqual(['paragraph', 'thinking'])
|
||||
expect(closedNodes[0]?.children?.map((node: any) => node?.type)).toEqual(['hardbreak'])
|
||||
expect(closedNodes[1]?.type).toBe('thinking')
|
||||
expect(closedNodes[1]?.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getMarkdown, parseMarkdownToStructure } from '../src'
|
||||
|
||||
describe('vmr_container fallback', () => {
|
||||
it('preserves args after container name as attrs', () => {
|
||||
const md = getMarkdown('vmr_container_args')
|
||||
const markdown = [
|
||||
':::test 1944565882923520000;tag;2',
|
||||
':::',
|
||||
].join('\n')
|
||||
|
||||
const nodes = parseMarkdownToStructure(markdown, md) as any[]
|
||||
expect(nodes[0]?.type).toBe('vmr_container')
|
||||
expect(nodes[0]?.name).toBe('test')
|
||||
expect(nodes[0]?.attrs?.args).toBe('1944565882923520000;tag;2')
|
||||
})
|
||||
|
||||
it('parses fenced code blocks inside ::: containers', () => {
|
||||
const md = getMarkdown('vmr_container_fence')
|
||||
const markdown = [
|
||||
'::: viewcode:topo-test-001 {"devId":"f318206374eb4ac7a3fb3b4b042fd01d"}',
|
||||
'```ts',
|
||||
'console.log("hello")',
|
||||
'```',
|
||||
':::',
|
||||
].join('\n')
|
||||
|
||||
const nodes = parseMarkdownToStructure(markdown, md) as any[]
|
||||
expect(nodes[0]?.type).toBe('vmr_container')
|
||||
expect(nodes[0]?.name).toBe('viewcode:topo-test-001')
|
||||
expect(nodes[0]?.attrs?.devId).toBe('f318206374eb4ac7a3fb3b4b042fd01d')
|
||||
|
||||
const children = nodes[0]?.children as any[]
|
||||
expect(Array.isArray(children)).toBe(true)
|
||||
|
||||
const code = children.find(n => n?.type === 'code_block')
|
||||
expect(code).toBeDefined()
|
||||
expect(code.language).toBe('ts')
|
||||
expect(String(code.code ?? '')).toContain('console.log')
|
||||
})
|
||||
|
||||
it('parses plain text inside ::: containers', () => {
|
||||
const md = getMarkdown('vmr_container_text')
|
||||
const cases = ['::: viewcode:plain-text', ':::viewcode:plain-text']
|
||||
|
||||
for (const openLine of cases) {
|
||||
const markdown = [
|
||||
openLine,
|
||||
'just some plain text',
|
||||
':::',
|
||||
].join('\n')
|
||||
const nodes = parseMarkdownToStructure(markdown, md) as any[]
|
||||
expect(nodes[0]?.type).toBe('vmr_container')
|
||||
expect(nodes[0]?.name).toBe('viewcode:plain-text')
|
||||
const children = nodes[0]?.children as any[]
|
||||
expect(children.length).toBe(1)
|
||||
expect(children[0]?.type).toBe('paragraph')
|
||||
expect(String(children[0]?.children?.[0]?.content ?? '')).toContain('plain text')
|
||||
}
|
||||
})
|
||||
|
||||
it('parses multiple blocks inside ::: containers', () => {
|
||||
const md = getMarkdown('vmr_container_blocks')
|
||||
const markdown = [
|
||||
'::: viewcode:multi-block',
|
||||
'First paragraph.',
|
||||
'',
|
||||
'```js',
|
||||
'alert(123)',
|
||||
'```',
|
||||
'',
|
||||
'- list item',
|
||||
':::',
|
||||
].join('\n')
|
||||
const nodes = parseMarkdownToStructure(markdown, md) as any[]
|
||||
// debug output
|
||||
|
||||
console.log('multi-block AST:', JSON.stringify(nodes, null, 2))
|
||||
expect(nodes[0]?.type).toBe('vmr_container')
|
||||
expect(nodes[0]?.name).toBe('viewcode:multi-block')
|
||||
const children = nodes[0]?.children as any[]
|
||||
expect(children.some(n => n.type === 'paragraph')).toBe(true)
|
||||
expect(children.some(n => n.type === 'code_block')).toBe(true)
|
||||
expect(children.some(n => n.type === 'list')).toBe(true)
|
||||
})
|
||||
|
||||
it('parses empty or whitespace-only containers', () => {
|
||||
const md = getMarkdown('vmr_container_empty')
|
||||
const markdown = [
|
||||
'::: viewcode:empty',
|
||||
' ',
|
||||
':::',
|
||||
].join('\n')
|
||||
const nodes = parseMarkdownToStructure(markdown, md) as any[]
|
||||
expect(nodes[0]?.type).toBe('vmr_container')
|
||||
expect(nodes[0]?.name).toBe('viewcode:empty')
|
||||
expect(Array.isArray(nodes[0]?.children)).toBe(true)
|
||||
expect(nodes[0]?.children.length).toBe(0)
|
||||
})
|
||||
|
||||
it('parses empty or whitespace-only containers -1', () => {
|
||||
const md = getMarkdown('vmr_container_empty')
|
||||
const markdown = [
|
||||
':::viewcode:empty',
|
||||
' ',
|
||||
':::',
|
||||
].join('\n')
|
||||
const nodes = parseMarkdownToStructure(markdown, md) as any[]
|
||||
expect(nodes[0]?.type).toBe('vmr_container')
|
||||
expect(nodes[0]?.name).toBe('viewcode:empty')
|
||||
expect(Array.isArray(nodes[0]?.children)).toBe(true)
|
||||
expect(nodes[0]?.children.length).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps content immediately above ::: as a separate node', () => {
|
||||
const md = getMarkdown('vmr_container_adjacent_above')
|
||||
const cases = ['::: viewcode:plain-text', ':::viewcode:plain-text']
|
||||
|
||||
for (const openLine of cases) {
|
||||
const markdown = [
|
||||
'Above text',
|
||||
openLine,
|
||||
'Inside text',
|
||||
':::',
|
||||
].join('\n')
|
||||
|
||||
const nodes = parseMarkdownToStructure(markdown, md) as any[]
|
||||
expect(nodes[0]?.type).toBe('paragraph')
|
||||
expect(String(nodes[0]?.children?.[0]?.content ?? '')).toContain('Above text')
|
||||
|
||||
expect(nodes[1]?.type).toBe('vmr_container')
|
||||
expect(nodes[1]?.name).toBe('viewcode:plain-text')
|
||||
|
||||
const children = nodes[1]?.children as any[]
|
||||
expect(children.length).toBe(1)
|
||||
expect(children[0]?.type).toBe('paragraph')
|
||||
expect(String(children[0]?.children?.[0]?.content ?? '')).toContain('Inside text')
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves complex JSON attrs (nested object/array)', () => {
|
||||
const md = getMarkdown('vmr_container_complex_attrs')
|
||||
const markdown = [
|
||||
'::: viewcode:complex {"devId":"abc","meta":{"foo":1,"bar":{"baz":[1,2,{"x":3}]}},"arr":[1,"2",{"k":true}]}',
|
||||
'Inside',
|
||||
':::',
|
||||
].join('\n')
|
||||
|
||||
const nodes = parseMarkdownToStructure(markdown, md) as any[]
|
||||
expect(nodes[0]?.type).toBe('vmr_container')
|
||||
expect(nodes[0]?.name).toBe('viewcode:complex')
|
||||
expect(nodes[0]?.attrs?.devId).toBe('abc')
|
||||
expect(nodes[0]?.attrs?.meta).toMatchInlineSnapshot(`
|
||||
{
|
||||
"bar": {
|
||||
"baz": [
|
||||
1,
|
||||
2,
|
||||
{
|
||||
"x": 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
"foo": 1,
|
||||
}
|
||||
`)
|
||||
expect(nodes[0]?.attrs?.arr).toMatchInlineSnapshot(`
|
||||
[
|
||||
1,
|
||||
"2",
|
||||
{
|
||||
"k": true,
|
||||
},
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
it('emits loading=true when ::: container is not closed (streaming mid-state)', () => {
|
||||
const md = getMarkdown('vmr_container_loading_mid')
|
||||
const markdown = [
|
||||
'::: viewcode:stream',
|
||||
'partial line',
|
||||
].join('\n')
|
||||
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: false }) as any[]
|
||||
expect(nodes[0]?.type).toBe('vmr_container')
|
||||
expect(nodes[0]?.name).toBe('viewcode:stream')
|
||||
expect(nodes[0]?.loading).toBe(true)
|
||||
})
|
||||
|
||||
it('sets loading=false once closing ::: is present', () => {
|
||||
const md = getMarkdown('vmr_container_loading_closed')
|
||||
const markdown = [
|
||||
'::: viewcode:stream',
|
||||
'content',
|
||||
':::',
|
||||
].join('\n')
|
||||
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: false }) as any[]
|
||||
expect(nodes[0]?.type).toBe('vmr_container')
|
||||
expect(nodes[0]?.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('forces loading=false when final=true even if closing ::: is missing', () => {
|
||||
const md = getMarkdown('vmr_container_loading_final')
|
||||
const markdown = [
|
||||
'::: viewcode:stream',
|
||||
'content',
|
||||
].join('\n')
|
||||
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
|
||||
expect(nodes[0]?.type).toBe('vmr_container')
|
||||
expect(nodes[0]?.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('parses attrs args payload in streaming mid-state (no closing :::)', () => {
|
||||
const md = getMarkdown('vmr_container_attrs_args_payload')
|
||||
const markdown = [
|
||||
'::: viewcode:stream xxx;yyy;ddd',
|
||||
].join('\n')
|
||||
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: false }) as any[]
|
||||
expect(nodes[0]?.type).toBe('vmr_container')
|
||||
expect(nodes[0]?.name).toBe('viewcode:stream')
|
||||
expect(nodes[0]?.attrs?.args).toBe('xxx;yyy;ddd')
|
||||
expect(nodes[0]?.loading).toBe(true)
|
||||
})
|
||||
|
||||
it('parses loose attrs payload in streaming mid-state (no closing :::)', () => {
|
||||
const md = getMarkdown('vmr_container_attrs_loose_object')
|
||||
const markdown = [
|
||||
'::: viewcode:stream {xxx:yyy}',
|
||||
].join('\n')
|
||||
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: false }) as any[]
|
||||
expect(nodes[0]?.type).toBe('vmr_container')
|
||||
expect(nodes[0]?.name).toBe('viewcode:stream')
|
||||
expect(nodes[0]?.attrs?.xxx).toBe('yyy')
|
||||
expect(nodes[0]?.loading).toBe(true)
|
||||
})
|
||||
|
||||
it('downgrades incomplete attrs payload to args in streaming mid-state attr', () => {
|
||||
const md = getMarkdown('vmr_container_attrs_loose_object')
|
||||
const markdown = [
|
||||
'::: viewcode:stream {xxx:yyy',
|
||||
].join('\n')
|
||||
|
||||
const nodes = parseMarkdownToStructure(markdown, md, { final: false }) as any[]
|
||||
|
||||
expect(nodes[0]?.type).toBe('vmr_container')
|
||||
expect(nodes[0]?.name).toBe('viewcode:stream')
|
||||
expect(nodes[0]?.attrs?.args).toBe('{xxx:yyy')
|
||||
expect(nodes[0]?.loading).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user