e0a5f57793
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.
412 lines
16 KiB
TypeScript
412 lines
16 KiB
TypeScript
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
|
||
import { describe, expect, it } from 'vitest'
|
||
import { links, textIncludes } from './utils/midstate-utils'
|
||
|
||
const md = getMarkdown('t')
|
||
|
||
const MARKDOWN = `**Markdown链接**:
|
||
1. [GitHub官网](https://github.com)
|
||
2. [知乎 - 有问题就会有答案](https://www.zhihu.com)
|
||
3. **加粗链接**:[Google](https://www.google.com)
|
||
4. 嵌套格式的链接:[*斜体链接*](https://example.com)
|
||
|
||
**普通链接**:
|
||
1. https://www.wikipedia.org
|
||
2. http://example.com/path?query=test
|
||
3. 纯文本URL:https://markdown-guide.readthedocs.io
|
||
`
|
||
|
||
describe('link parsing', () => {
|
||
it('parses inline and bare links into link nodes', () => {
|
||
const nodes = parseMarkdownToStructure(MARKDOWN, md)
|
||
// Flatten paragraph children for assertions
|
||
const allLinkNodes: any[] = []
|
||
const collect = (n: any) => {
|
||
if (!n)
|
||
return
|
||
if (n.type === 'link')
|
||
allLinkNodes.push(n)
|
||
if (Array.isArray(n.children)) {
|
||
for (const c of n.children) collect(c)
|
||
}
|
||
if (Array.isArray(n.items)) {
|
||
for (const it of n.items) collect(it)
|
||
}
|
||
}
|
||
for (const n of nodes) collect(n)
|
||
|
||
// Some links may initially be loading (href empty) but should include texts
|
||
const texts = allLinkNodes.map(l => l.text?.toString() || '')
|
||
|
||
expect(texts).toContain('GitHub官网')
|
||
expect(texts).toContain('知乎 - 有问题就会有答案')
|
||
expect(texts).toContain('Google')
|
||
expect(texts).toContain('https://www.wikipedia.org')
|
||
expect(texts).toContain('http://example.com/path?query=test')
|
||
expect(texts).toContain('https://markdown-guide.readthedocs.io')
|
||
|
||
// The parser may keep emphasis markup in link.text (e.g. "*斜体链接*"),
|
||
// so accept either the raw form or verify the nested emphasis child contains the plain text.
|
||
// The parser may keep emphasis markup in link.text or emit nested emphasis nodes.
|
||
// Accept any of these by inspecting the link nodes with the tolerant helper.
|
||
const foundItalic = allLinkNodes.some(l =>
|
||
textIncludes(l, '斜体链接') || textIncludes(l, '*斜体链接*') || textIncludes(l, '_斜体链接_'),
|
||
)
|
||
expect(foundItalic).toBe(true)
|
||
|
||
// Check for bare URLs rendered as text nodes inside paragraphs
|
||
const containsBare = nodes.some((n: any) => {
|
||
return textIncludes(n, 'https://www.wikipedia.org') || textIncludes(n, 'http://example.com/path?query=test') || textIncludes(n, 'https://markdown-guide.readthedocs.io')
|
||
})
|
||
expect(containsBare).toBe(true)
|
||
})
|
||
|
||
it('test the link tag', () => {
|
||
const nodes = parseMarkdownToStructure(`<a href="https://example.com">Example</a>`, md) as any
|
||
const p = nodes[0]
|
||
const children = p.children
|
||
expect(children.length).toBe(1)
|
||
const link = p.children[0]
|
||
expect(link.type).toBe('link')
|
||
expect(link.href).toBe('https://example.com')
|
||
expect(link.text).toBe('Example')
|
||
expect(link.children[0].type).toBe('text')
|
||
expect(link.children[0].content).toBe('Example')
|
||
})
|
||
|
||
it('test the intermediate state of the link tag', () => {
|
||
const nodes = parseMarkdownToStructure(`<a href="https://example.com">`, md) as any
|
||
expect(nodes.length).toBe(1)
|
||
})
|
||
|
||
it('parses link with parentheses and CJK brackets as a single link', () => {
|
||
const special = '[【名称】(test).mp4](https://github.com/Simon-He95/markstream-vueer)'
|
||
const nodes = parseMarkdownToStructure(special, md)
|
||
|
||
// Flatten and collect 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)
|
||
// Expect only a single link node in the paragraph without stray text nodes
|
||
expect(links.length).toBe(1)
|
||
expect(links[0].href).toBe('https://github.com/Simon-He95/markstream-vueer')
|
||
// Ensure the full visible text is preserved inside the link
|
||
// Accept either direct text aggregation or nested children text
|
||
const text = links[0].text || ''
|
||
const childText = (links[0].children || [])
|
||
.map((c: any) => (c.content ?? c.text ?? ''))
|
||
.join('')
|
||
expect(text || childText).toBe('【名称】(test).mp4')
|
||
|
||
// Also assert the paragraph has exactly one child which is the link
|
||
const para = nodes.find((n: any) => n.type === 'paragraph') as any
|
||
expect(para).toBeTruthy()
|
||
expect(Array.isArray(para.children)).toBe(true)
|
||
expect(para.children.length).toBe(1)
|
||
expect(para.children[0].type).toBe('link')
|
||
})
|
||
|
||
it('parses link with parentheses and emphasis and ListNode', () => {
|
||
const special = '- **[Link (Test 1)](https://simonhe.me/)**'
|
||
const nodes = parseMarkdownToStructure(special, md)
|
||
expect(nodes[0]).toMatchInlineSnapshot(`
|
||
{
|
||
"items": [
|
||
{
|
||
"children": [
|
||
{
|
||
"children": [
|
||
{
|
||
"children": [
|
||
{
|
||
"attrs": [
|
||
[
|
||
"href",
|
||
"https://simonhe.me/",
|
||
],
|
||
],
|
||
"children": [
|
||
{
|
||
"center": false,
|
||
"content": "Link (Test 1)",
|
||
"raw": "Link (Test 1)",
|
||
"type": "text",
|
||
},
|
||
],
|
||
"href": "https://simonhe.me/",
|
||
"loading": false,
|
||
"raw": "[Link (Test 1)](https://simonhe.me/)",
|
||
"text": "Link (Test 1)",
|
||
"title": null,
|
||
"type": "link",
|
||
},
|
||
],
|
||
"raw": "**Link (Test 1)**",
|
||
"type": "strong",
|
||
},
|
||
],
|
||
"raw": "**[Link (Test 1)](https://simonhe.me/)**",
|
||
"type": "paragraph",
|
||
},
|
||
],
|
||
"raw": "**[Link (Test 1)](https://simonhe.me/)**",
|
||
"type": "list_item",
|
||
},
|
||
],
|
||
"ordered": false,
|
||
"raw": "**[Link (Test 1)](https://simonhe.me/)**",
|
||
"start": undefined,
|
||
"type": "list",
|
||
}
|
||
`)
|
||
})
|
||
|
||
it('parses link with parentheses and emphasis', () => {
|
||
const special = '**[Link (Test 2)](https://simonhe.me/)**'
|
||
const nodes = parseMarkdownToStructure(special, md)
|
||
expect(nodes[0]).toMatchInlineSnapshot(`
|
||
{
|
||
"children": [
|
||
{
|
||
"children": [
|
||
{
|
||
"attrs": [
|
||
[
|
||
"href",
|
||
"https://simonhe.me/",
|
||
],
|
||
],
|
||
"children": [
|
||
{
|
||
"center": false,
|
||
"content": "Link (Test 2)",
|
||
"raw": "Link (Test 2)",
|
||
"type": "text",
|
||
},
|
||
],
|
||
"href": "https://simonhe.me/",
|
||
"loading": false,
|
||
"raw": "[Link (Test 2)](https://simonhe.me/)",
|
||
"text": "Link (Test 2)",
|
||
"title": null,
|
||
"type": "link",
|
||
},
|
||
],
|
||
"raw": "**Link (Test 2)**",
|
||
"type": "strong",
|
||
},
|
||
],
|
||
"raw": "**[Link (Test 2)](https://simonhe.me/)**",
|
||
"type": "paragraph",
|
||
}
|
||
`)
|
||
})
|
||
|
||
it('parses emphasized link with asterisk inside list item', () => {
|
||
const special = '- [*DR (Danmarks Radio)*](https://www.dr.dk/nyheder)'
|
||
const nodes = parseMarkdownToStructure(special, md)
|
||
|
||
// Collect 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)
|
||
|
||
expect(links.length).toBe(1)
|
||
expect(links[0].href).toBe('https://www.dr.dk/nyheder')
|
||
// The parser may keep emphasis markup in link.text or emit nested emphasis nodes.
|
||
// Accept either form by using the tolerant helper.
|
||
const found
|
||
= textIncludes(links[0], 'DR (Danmarks Radio)')
|
||
|| textIncludes(links[0], '*DR (Danmarks Radio)*')
|
||
|| textIncludes(links[0], '_DR (Danmarks Radio)_')
|
||
expect(found).toBe(true)
|
||
})
|
||
|
||
it('parses URL with inline trailing text (no newline)', () => {
|
||
const mdText = `第一个气泡(无换行):
|
||
http://127.0.0.1:8001/upload/20251118/4737bbe0-c42e-11f0-8471-37360564882d.docx 请在文档末尾增加一段示例内容`
|
||
const nodes = parseMarkdownToStructure(mdText, md)
|
||
|
||
expect(
|
||
textIncludes(
|
||
nodes,
|
||
'http://127.0.0.1:8001/upload/20251118/4737bbe0-c42e-11f0-8471-37360564882d.docx',
|
||
),
|
||
).toBe(true)
|
||
expect(textIncludes(nodes, '请在文档末尾增加一段示例内容')).toBe(true)
|
||
})
|
||
|
||
it('parses URL followed by newline and trailing text', () => {
|
||
const mdText = `第二个气泡(有换行):
|
||
http://127.0.0.1:8001/upload/20251118/4737bbe0-c42e-11f0-8471-37360564882d.docx
|
||
请在文档末尾增加一段示例内容`
|
||
const nodes = parseMarkdownToStructure(mdText, md)
|
||
|
||
expect(
|
||
textIncludes(
|
||
nodes,
|
||
'http://127.0.0.1:8001/upload/20251118/4737bbe0-c42e-11f0-8471-37360564882d.docx',
|
||
),
|
||
).toBe(true)
|
||
expect(textIncludes(nodes, '请在文档末尾增加一段示例内容')).toBe(true)
|
||
|
||
// Ensure a hard line break node is present for the newline between URL and trailing text
|
||
let foundHardBreak = false
|
||
const walk = (n: any) => {
|
||
if (!n)
|
||
return
|
||
if (n.type === 'hardbreak')
|
||
foundHardBreak = true
|
||
if (Array.isArray(n.children))
|
||
n.children.forEach(walk)
|
||
if (Array.isArray(n.items))
|
||
n.items.forEach(walk)
|
||
}
|
||
nodes.forEach(walk)
|
||
expect(foundHardBreak).toBe(true)
|
||
})
|
||
|
||
it('parses Danish list item with parenthetical URL', () => {
|
||
const special = '- **Kilde:**\u202FGrammatip – “Tekstretter” (https://www.grammatip.com/tekstretter) – her beskrives, hvordan værktøjet automatisk markerer fejl, giver fejltyper, leverer farvekodet feedback, giver lærerne et samlet overblik og muligheden for at tilpasse, hvilke fejl eleven skal se.'
|
||
const nodes = parseMarkdownToStructure(special, md)
|
||
|
||
// Find the paragraph node inside the list item
|
||
let para: any = null
|
||
const findPara = (n: any) => {
|
||
if (!n)
|
||
return
|
||
if (n.type === 'paragraph') {
|
||
para = n
|
||
return
|
||
}
|
||
if (Array.isArray(n.children))
|
||
n.children.forEach(findPara)
|
||
if (Array.isArray(n.items))
|
||
n.items.forEach(findPara)
|
||
}
|
||
nodes.forEach(findPara)
|
||
expect(para).toBeTruthy()
|
||
|
||
// Flatten paragraph descendants into ordered tokens, marking links specially
|
||
const tokens: string[] = []
|
||
const flatten = (n: any) => {
|
||
if (!n)
|
||
return
|
||
if (n.type === 'link') {
|
||
tokens.push(`<LINK:${n.href || n.text || ''}>`)
|
||
return
|
||
}
|
||
if (n.type === 'text') {
|
||
tokens.push(n.content || n.text || '')
|
||
return
|
||
}
|
||
if (Array.isArray(n.children)) {
|
||
for (const c of n.children) flatten(c)
|
||
}
|
||
}
|
||
flatten(para)
|
||
|
||
// There should be at least one link token with the expected href
|
||
const linkIndex = tokens.findIndex(t => t.startsWith('<LINK:'))
|
||
expect(linkIndex).toBeGreaterThanOrEqual(0)
|
||
const linkToken = tokens[linkIndex]
|
||
expect(linkToken).toContain('https://www.grammatip.com/tekstretter')
|
||
|
||
// Check text immediately before the link contains the title/context
|
||
const before = tokens[linkIndex - 1] || ''
|
||
const hasBefore = before.includes('Tekstretter') || before.includes('Grammatip') || before.includes('Kilde')
|
||
expect(hasBefore).toBe(true)
|
||
|
||
// Check text immediately after the link contains the trailing explanation
|
||
const afterTokens = tokens.slice(linkIndex + 1)
|
||
const afterFull = afterTokens.join('').trim()
|
||
const expectedAfter = ') – her beskrives, hvordan værktøjet automatisk markerer fejl, giver fejltyper, leverer farvekodet feedback, giver lærerne et samlet overblik og muligheden for at tilpasse, hvilke fejl eleven skal se.'
|
||
expect(afterFull).toBe(expectedAfter)
|
||
})
|
||
|
||
it('does not include ! suffix in linkify/autolink bare URLs', () => {
|
||
const cjkBang = '图表URL: https://www.baidu.com/img/flexible/logo/pc/result.png!文字文字'
|
||
const nodes1 = parseMarkdownToStructure(cjkBang, md)
|
||
const l1 = links(nodes1)
|
||
expect(l1.length).toBe(1)
|
||
expect(l1[0].href).toBe('https://www.baidu.com/img/flexible/logo/pc/result.png')
|
||
expect(l1[0].text).toBe('https://www.baidu.com/img/flexible/logo/pc/result.png')
|
||
expect(textIncludes(nodes1, '!文字文字')).toBe(true)
|
||
expect(textIncludes(l1[0], '!')).toBe(false)
|
||
|
||
const angle = '<https://www.baidu.com/img/flexible/logo/pc/result.png!文字文字>'
|
||
const nodes3 = parseMarkdownToStructure(angle, md)
|
||
const l3 = links(nodes3)
|
||
expect(l3.length).toBe(1)
|
||
expect(l3[0].href).toBe('https://www.baidu.com/img/flexible/logo/pc/result.png')
|
||
expect(l3[0].text).toBe('https://www.baidu.com/img/flexible/logo/pc/result.png')
|
||
expect(textIncludes(nodes3, '!文字文字')).toBe(true)
|
||
expect(textIncludes(l3[0], '!')).toBe(false)
|
||
})
|
||
|
||
it('keeps ASCII ! when it is part of the URL', () => {
|
||
const mdText = [
|
||
'https://example.com/a!b',
|
||
'https://example.com/?q=!',
|
||
'https://example.com/#!',
|
||
'<https://example.com/a!b>',
|
||
].join('\n')
|
||
const nodes = parseMarkdownToStructure(mdText, md)
|
||
const ls = links(nodes)
|
||
const hrefs = ls.map((l: any) => String(l.href || ''))
|
||
expect(hrefs).toContain('https://example.com/a!b')
|
||
expect(hrefs).toContain('https://example.com/?q=!')
|
||
expect(hrefs).toContain('https://example.com/#!')
|
||
})
|
||
|
||
it('does not merge punctuation ! into explicit markdown links', () => {
|
||
const nodes = parseMarkdownToStructure('[x](https://example.com/#)!', md)
|
||
const ls = links(nodes)
|
||
expect(ls.length).toBe(1)
|
||
expect(ls[0].href).toBe('https://example.com/#')
|
||
expect(ls[0].text).toBe('x')
|
||
expect(textIncludes(nodes, '!')).toBe(true)
|
||
})
|
||
|
||
it('respects customMarkdownIt validateLink: rejects javascript: and renders as plain text', () => {
|
||
const mdSafe = getMarkdown('validate-link-test')
|
||
mdSafe.set?.({ validateLink: (url: string) => !/^\s*javascript:/i.test(url.trim()) })
|
||
const nodes = parseMarkdownToStructure('[click me](javascript:alert(1))', mdSafe, { final: true })
|
||
const ls = links(nodes)
|
||
expect(ls.length).toBe(0)
|
||
expect(textIncludes(nodes, 'click me')).toBe(true)
|
||
// No link node may have javascript: href (paragraph.raw may still hold original source)
|
||
const anyBadHref = ls.some((l: any) => String(l.href || '').toLowerCase().includes('javascript:'))
|
||
expect(anyBadHref).toBe(false)
|
||
})
|
||
|
||
it('respects customMarkdownIt validateLink: allows https when validateLink returns true', () => {
|
||
const mdSafe = getMarkdown('validate-link-allow')
|
||
mdSafe.set?.({ validateLink: (url: string) => url.startsWith('https://') })
|
||
const nodes = parseMarkdownToStructure('[safe](https://example.com) [unsafe](http://example.com)', mdSafe, { final: true })
|
||
const ls = links(nodes)
|
||
expect(ls.length).toBe(1)
|
||
expect(ls[0].href).toBe('https://example.com')
|
||
expect(ls[0].text).toBe('safe')
|
||
expect(textIncludes(nodes, 'unsafe')).toBe(true)
|
||
})
|
||
})
|