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.
881 lines
38 KiB
TypeScript
881 lines
38 KiB
TypeScript
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
|
|
import { beforeAll, describe, expect, it } from 'vitest'
|
|
import { collect, fenceOrParagraph, hasEmoji, hasLoadingLink, hasNode, hasParagraph, links, paragraphFirst, paragraphHasCheckbox, textIncludes } from './utils/midstate-utils'
|
|
|
|
const md = getMarkdown('midstates')
|
|
|
|
beforeAll(async () => {
|
|
// Tests opt-in to emoji support explicitly so the library avoids bundling
|
|
// it by default in production. Register emoji plugin on the named md
|
|
// instance used in these midstate tests.
|
|
const emoji = await import('markdown-it-emoji')
|
|
md.use((emoji as any).full)
|
|
})
|
|
|
|
// `collect` imported from test/utils/midstate-utils
|
|
|
|
describe('parseMarkdownToStructure - incremental/mid-typing states', () => {
|
|
describe('heading transitions (extra)', () => {
|
|
it('"##x" stays paragraph, then "## x" becomes heading level 2', () => {
|
|
const n1 = parseMarkdownToStructure('##x', md)
|
|
expect(hasNode(n1, 'heading')).toBeFalsy()
|
|
const n2 = parseMarkdownToStructure('## x', md)
|
|
const h = collect(n2, 'heading')[0] as any
|
|
expect(h).toBeTruthy()
|
|
expect(h.level).toBe(2)
|
|
})
|
|
})
|
|
|
|
describe('heading transitions', () => {
|
|
it('"#x" stays paragraph, then "# x" becomes heading level 1', () => {
|
|
const n1 = parseMarkdownToStructure('#x', md)
|
|
expect(hasNode(n1, 'heading')).toBeFalsy()
|
|
const p1: any = collect(n1, 'paragraph')[0]
|
|
expect(textIncludes(p1?.raw || n1, '#x')).toBe(true)
|
|
|
|
const n2 = parseMarkdownToStructure('# x', md)
|
|
const h = collect(n2, 'heading')[0] as any
|
|
expect(h).toBeTruthy()
|
|
expect(h.level).toBe(1)
|
|
const text = (h.children?.[0]?.content) || (h.children?.[0]?.text) || ''
|
|
// tolerant check: prefer structural helper but allow string fallback
|
|
expect(textIncludes(h, 'x') || String(text).includes('x')).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('blockquote transitions', () => {
|
|
it('"> x" becomes blockquote with paragraph child', () => {
|
|
const n = parseMarkdownToStructure('> x', md)
|
|
const bq = collect(n, 'blockquote')[0] as any
|
|
expect(bq).toBeTruthy()
|
|
const innerPara = bq.children?.find((c: any) => c.type === 'paragraph')
|
|
expect(innerPara).toBeTruthy()
|
|
})
|
|
})
|
|
|
|
describe('list item transitions', () => {
|
|
it('"- x" becomes a bullet list item when followed by newline', () => {
|
|
const n1 = parseMarkdownToStructure('- x', md)
|
|
// Might still be paragraph depending on markdown-it rules without newline
|
|
const maybeList = collect(n1, 'list')[0]
|
|
const maybePara = collect(n1, 'paragraph')[0]
|
|
expect(!!maybeList || !!maybePara).toBe(true)
|
|
|
|
const n2 = parseMarkdownToStructure('- x\n', md)
|
|
const list = collect(n2, 'list')[0] as any
|
|
expect(list).toBeTruthy()
|
|
expect(list.ordered).toBe(false)
|
|
const firstItemPara = list.items?.[0]?.children?.find((c: any) => c.type === 'paragraph')
|
|
const text = firstItemPara?.children?.find((c: any) => c.type === 'text')
|
|
expect(text?.content).toContain('x')
|
|
})
|
|
|
|
it('"1. x" becomes an ordered list when followed by newline', () => {
|
|
const n = parseMarkdownToStructure('1. x\n', md)
|
|
const list = collect(n, 'list')[0] as any
|
|
expect(list).toBeTruthy()
|
|
expect(list.ordered).toBe(true)
|
|
// start defaults to 1 if omitted
|
|
expect(list.start === undefined || list.start === 1).toBeTruthy()
|
|
})
|
|
|
|
it('"- *" mid-state should not explode or produce nested lists unintentionally', () => {
|
|
const n = parseMarkdownToStructure('- *', md)
|
|
// The parser has a guard to escape this case; accept paragraph fallback
|
|
const para = collect(n, 'paragraph')[0]
|
|
const list = collect(n, 'list')[0]
|
|
expect(!!para || !!list).toBe(true)
|
|
expect(textIncludes((para as any)?.raw || n, '- *') || !!list).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('emphasis/strong mid-states', () => {
|
|
it('"*x" parses as emphasis, "**x" as strong (for mid typing)', () => {
|
|
const e = parseMarkdownToStructure('*x', md)
|
|
const em = collect(e, 'emphasis')[0]
|
|
expect(em).toBeTruthy()
|
|
const emText = em.children?.[0]?.content || em.text || ''
|
|
expect(textIncludes(em, 'x') || String(emText).includes('x')).toBe(true)
|
|
|
|
const s = parseMarkdownToStructure('**x', md)
|
|
const strong = collect(s, 'strong')[0]
|
|
expect(strong).toBeTruthy()
|
|
const st = strong.children?.[0]?.content || strong.text || ''
|
|
expect(textIncludes(strong, 'x') || String(st).includes('x')).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('inline code mid-states', () => {
|
|
it('"`x" yields an inline_code node; "`x`" closes it', () => {
|
|
const a = parseMarkdownToStructure('`x', md)
|
|
const codeA = collect(a, 'inline_code')[0] as any
|
|
expect(codeA).toBeTruthy()
|
|
// Prefer structural match but accept raw/code fallback
|
|
expect(textIncludes(codeA, 'x') || String(codeA.code || codeA.raw || '').includes('x')).toBe(true)
|
|
|
|
const b = parseMarkdownToStructure('`x`', md)
|
|
const codeB = collect(b, 'inline_code')[0] as any
|
|
expect(codeB).toBeTruthy()
|
|
expect(codeB.code).toBe('x')
|
|
})
|
|
})
|
|
|
|
describe('link transitions including tricky mid-states', () => {
|
|
it('"[*x" and "[**x" should NOT be parsed as a link yet', () => {
|
|
for (const input of ['[*x', '[**x']) {
|
|
const nodes = parseMarkdownToStructure(input, md)
|
|
const links = collect(nodes, 'link')
|
|
expect(links.length).toBe(0)
|
|
// Ensure original bracket remains in text output
|
|
expect(textIncludes(nodes, '[*x') || textIncludes(nodes, '[**x')).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('"[知乎 - 有问题就会有答案]" mid-state should not be parsed as math', () => {
|
|
const mid = parseMarkdownToStructure('[知乎 - 有问题就会有答案]', md)
|
|
expect(collect(mid, 'math_block').length).toBe(0)
|
|
expect(textIncludes(mid, '知乎 - 有问题就会有答案')).toBe(true)
|
|
|
|
const full = parseMarkdownToStructure('[知乎 - 有问题就会有答案](https://www.zhihu.com)', md)
|
|
const ls = collect(full, 'link') as any[]
|
|
expect(ls.length).toBe(1)
|
|
expect(ls[0].href).toBe('https://www.zhihu.com')
|
|
expect(ls[0].loading).toBe(false)
|
|
})
|
|
|
|
it('"[x](http://a" produces a loading link (no closing paren)', () => {
|
|
const nodes = parseMarkdownToStructure('[x](http://a', md)
|
|
// Accept either provisional loading link or tolerant fallback
|
|
const ls = links(nodes)
|
|
if (ls.length > 0) {
|
|
const l = ls[0]
|
|
expect(l.text).toBe('x')
|
|
expect(!!l.loading).toBe(true)
|
|
}
|
|
else {
|
|
expect(textIncludes(nodes, '[x](http://a')).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('"[x](http://a)" produces a finalized link', () => {
|
|
const nodes = parseMarkdownToStructure('[x](http://a)', md)
|
|
const links = collect(nodes, 'link') as any[]
|
|
expect(links.length).toBe(1)
|
|
expect(links[0].href).toBe('http://a')
|
|
expect(links[0].loading).toBe(false)
|
|
})
|
|
|
|
it('handles "[*x](xx" mid-state tolerantly: either a loading link or plain text with emphasis', () => {
|
|
const nodes = parseMarkdownToStructure('[*x](xx', md)
|
|
const ls = links(nodes)
|
|
if (ls.length > 0) {
|
|
const l = ls[0]
|
|
const textOk = l.text === 'x' || (l.children && textIncludes(l.children, 'emphasis'))
|
|
expect(textOk).toBe(true)
|
|
expect(!!l.loading).toBe(true)
|
|
}
|
|
else {
|
|
// Accept tokenizer choosing to keep as text + emphasis until closing paren exists
|
|
expect(textIncludes(nodes, '[*x](xx') || textIncludes(nodes, 'emphasis')).toBe(true)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('image transitions', () => {
|
|
it('" => {
|
|
const nodes = parseMarkdownToStructure('
|
|
// Depending on tokenizer, an image may already be emitted or fallback to text
|
|
const images = collect(nodes, 'image')
|
|
expect(images.length >= 0).toBe(true)
|
|
expect(images.length > 0 || textIncludes(nodes, ').toBe(true)
|
|
})
|
|
|
|
it('"" becomes an image node', () => {
|
|
const nodes = parseMarkdownToStructure('', md)
|
|
const images = collect(nodes, 'image') as any[]
|
|
expect(images.length).toBe(1)
|
|
expect(images[0].src || images[0].href).toBe('http://img')
|
|
})
|
|
})
|
|
|
|
describe('checkbox list item transitions', () => {
|
|
it('"- [" is a mid-state and should not crash', () => {
|
|
const nodes = parseMarkdownToStructure('- [', md)
|
|
// Accept paragraph or list fallback safely
|
|
const ok = hasParagraph(nodes) || hasNode(nodes, 'list')
|
|
expect(ok).toBe(true)
|
|
})
|
|
|
|
it('"- [ ] x" becomes an unchecked checkbox item when newline present', () => {
|
|
const nodes = parseMarkdownToStructure('- [ ] x\n', md)
|
|
const list = collect(nodes, 'list')[0] as any
|
|
expect(list).toBeTruthy()
|
|
const item = list.items?.[0]
|
|
const para = item?.children?.find((c: any) => c.type === 'paragraph')
|
|
expect(!!para).toBe(true)
|
|
// checkbox node is produced as an inline child of the paragraph
|
|
expect(paragraphHasCheckbox([item])).toBe(true)
|
|
const text = para?.children?.find((c: any) => c.type === 'text')
|
|
expect(text?.content || '').toContain('x')
|
|
})
|
|
|
|
it('"- [x] x" becomes a checked checkbox item', () => {
|
|
const nodes = parseMarkdownToStructure('- [x] hello\n', md)
|
|
const list = collect(nodes, 'list')[0] as any
|
|
expect(list).toBeTruthy()
|
|
const item = list.items?.[0]
|
|
const para = item?.children?.find((c: any) => c.type === 'paragraph')
|
|
expect(!!para).toBe(true)
|
|
expect(paragraphHasCheckbox([item])).toBe(true)
|
|
// checked could be on either checkbox or checkbox_input node
|
|
const checkbox = paragraphFirst([item])?.children?.find((c: any) => c.type === 'checkbox')
|
|
|| paragraphFirst([item])?.children?.find((c: any) => c.type === 'checkbox_input')
|
|
const checked = checkbox?.checked ?? checkbox?.attrs?.checked
|
|
expect(checked).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('thematic break transitions', () => {
|
|
it('"-" and "--" are not hr; "---" is hr', () => {
|
|
const a = parseMarkdownToStructure('-', md)
|
|
expect(collect(a, 'thematic_break').length).toBe(0)
|
|
const b = parseMarkdownToStructure('--', md)
|
|
expect(collect(b, 'thematic_break').length).toBe(0)
|
|
const c = parseMarkdownToStructure('---', md)
|
|
expect(collect(c, 'thematic_break').length).toBeGreaterThanOrEqual(1)
|
|
})
|
|
})
|
|
|
|
describe('hardbreak transitions', () => {
|
|
it('two spaces before newline produces a hardbreak; one space does not', () => {
|
|
const no = parseMarkdownToStructure('line \nnext', md)
|
|
expect(collect(no, 'hardbreak').length).toBe(0)
|
|
const yes = parseMarkdownToStructure('line \nnext', md)
|
|
expect(collect(yes, 'hardbreak').length).toBeGreaterThanOrEqual(1)
|
|
})
|
|
})
|
|
|
|
describe('footnote reference mid-states', () => {
|
|
it('"[^1" remains text; "[^1]" may become a footnote_reference depending on plugins', () => {
|
|
const a = parseMarkdownToStructure('[^1', md)
|
|
expect(collect(a, 'footnote_reference').length).toBe(0)
|
|
const b = parseMarkdownToStructure('[^1]', md)
|
|
// token type may be 'footnote_reference' or fall back to text if plugin config defers
|
|
const got = collect(b, 'footnote_reference')
|
|
if (got.length === 0) {
|
|
expect(textIncludes(b, '[^1]')).toBe(true)
|
|
}
|
|
else {
|
|
expect(got.length).toBeGreaterThanOrEqual(1)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('reference-style link mid-states', () => {
|
|
it('"[text][id" stays as text/reference token; "[text][id]" recognized as reference', () => {
|
|
const a = parseMarkdownToStructure('[text][id', md)
|
|
// May yield a 'reference' inline token fallback or plain text
|
|
const okA = collect(a, 'reference').length >= 0
|
|
expect(okA).toBe(true)
|
|
const b = parseMarkdownToStructure('[text][id]', md)
|
|
// Depending on plugin setup, may still be a reference token awaiting definition
|
|
expect(collect(b, 'reference').length >= 0).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('numeric reference mid-states', () => {
|
|
it('keeps completed numeric references stable while the next numeric reference is still incomplete', () => {
|
|
const mid = parseMarkdownToStructure('引用[1][2', md)
|
|
const midRefs = collect(mid, 'reference') as any[]
|
|
expect(midRefs).toHaveLength(1)
|
|
expect(midRefs[0]?.id).toBe('1')
|
|
expect(textIncludes(mid, '[2')).toBe(true)
|
|
|
|
const full = parseMarkdownToStructure('引用[1][2]', md)
|
|
const fullRefs = collect(full, 'reference') as any[]
|
|
expect(fullRefs.map(ref => ref.id)).toEqual(['1', '2'])
|
|
})
|
|
|
|
it('keeps the numeric reference when a following inline link has only opened its href', () => {
|
|
const nodes = parseMarkdownToStructure('引用 [1][百度](', md)
|
|
const refs = collect(nodes, 'reference') as any[]
|
|
expect(refs).toHaveLength(1)
|
|
expect(refs[0]?.id).toBe('1')
|
|
expect(textIncludes(nodes, '百度')).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('fenced code block mid-states', () => {
|
|
it('"```" or "```js" mid-state: accept fence node or paragraph fallback', () => {
|
|
for (const input of ['```', '```js', '```js\nconsole.log(1)']) {
|
|
const nodes = parseMarkdownToStructure(input, md)
|
|
expect(fenceOrParagraph(nodes)).toBe(true)
|
|
}
|
|
})
|
|
it('mermaid fence: unclosed vs closed', () => {
|
|
const unclosed = parseMarkdownToStructure('```mermaid\nflowchart TD\nA-->B', md)
|
|
const f1 = collect(unclosed, 'code_block') as any[]
|
|
if (f1.length > 0) {
|
|
// If recognized early, language should be mermaid and likely loading
|
|
expect(f1[0].language).toBe('mermaid')
|
|
// loading may be true for unclosed fences
|
|
expect([true, false]).toContain(!!f1[0].loading)
|
|
}
|
|
else {
|
|
// Accept paragraph fallback
|
|
expect(hasParagraph(unclosed)).toBe(true)
|
|
}
|
|
|
|
const closed = parseMarkdownToStructure('```mermaid\nflowchart TD\nA-->B\n```', md)
|
|
const f2 = collect(closed, 'code_block') as any[]
|
|
expect(f2.length).toBeGreaterThanOrEqual(1)
|
|
expect(f2[0].language).toBe('mermaid')
|
|
// closed fences should be loading=false when mapping closed is detected
|
|
if (typeof f2[0].loading === 'boolean')
|
|
expect(f2[0].loading).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('table transitions', () => {
|
|
it('"a | b" alone is not a table; add separator to form a table', () => {
|
|
const a = parseMarkdownToStructure('a | b', md)
|
|
expect(collect(a, 'table').length).toBe(0)
|
|
const b = parseMarkdownToStructure('a | b\n---|---', md)
|
|
expect(collect(b, 'table').length).toBeGreaterThanOrEqual(1)
|
|
})
|
|
it('alignment separators (:\n---: etc.) should not be parsed as emoji', () => {
|
|
const s = parseMarkdownToStructure('A | B\n:---:|---', md)
|
|
const table = collect(s, 'table')[0]
|
|
expect(!!table).toBe(true)
|
|
// Ensure no stray emoji nodes in header/separator processing
|
|
const emojis = collect(s, 'emoji')
|
|
expect(emojis.length).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('marking mid-states (strikethrough/highlight/insert/sub/sup)', () => {
|
|
it('strikethrough: "~~x" or "~~x~~" should yield a strikethrough node', () => {
|
|
for (const input of ['~~x', '~~x~~']) {
|
|
const nodes = parseMarkdownToStructure(input, md)
|
|
const s = collect(nodes, 'strikethrough')
|
|
expect(s.length).toBeGreaterThanOrEqual(1)
|
|
}
|
|
})
|
|
it('highlight: "==x" may stay text until closed; "==x==" highlights', () => {
|
|
const a = parseMarkdownToStructure('==x', md)
|
|
expect(collect(a, 'highlight').length).toBe(0)
|
|
const b = parseMarkdownToStructure('==x==', md)
|
|
expect(collect(b, 'highlight').length).toBeGreaterThanOrEqual(1)
|
|
})
|
|
it('insert/underline: "++x" may stay text; "++x++" inserts', () => {
|
|
const a = parseMarkdownToStructure('++x', md)
|
|
expect(collect(a, 'insert').length).toBe(0)
|
|
const b = parseMarkdownToStructure('++x++', md)
|
|
expect(collect(b, 'insert').length).toBeGreaterThanOrEqual(1)
|
|
})
|
|
it('superscript and subscript mid-states', () => {
|
|
const supA = parseMarkdownToStructure('^2', md)
|
|
expect(collect(supA, 'superscript').length).toBe(0)
|
|
const supB = parseMarkdownToStructure('^2^', md)
|
|
expect(collect(supB, 'superscript').length).toBeGreaterThanOrEqual(1)
|
|
|
|
const subA = parseMarkdownToStructure('~2', md)
|
|
expect(collect(subA, 'subscript').length).toBe(0)
|
|
const subB = parseMarkdownToStructure('~2~', md)
|
|
expect(collect(subB, 'subscript').length).toBeGreaterThanOrEqual(1)
|
|
})
|
|
})
|
|
|
|
describe('emoji with links/lists mix edge cases', () => {
|
|
it('list item mixing emoji and link (mid-state link)', () => {
|
|
const nodes = parseMarkdownToStructure('- :smile: [Go](http://a', md)
|
|
const list = collect(nodes, 'list')[0] as any
|
|
expect(!!list).toBe(true)
|
|
const para = list.items?.[0]?.children?.find((c: any) => c.type === 'paragraph')
|
|
expect(!!para).toBe(true)
|
|
const hasEmojiInPara = hasEmoji(para?.children || [])
|
|
const links = para ? para.children.filter((c: any) => c.type === 'link') : []
|
|
// Link may be recognized as loading link at this stage
|
|
const ok = hasEmojiInPara && (links.length >= 0)
|
|
expect(ok).toBe(true)
|
|
})
|
|
it('link text may contain emoji shortcode', () => {
|
|
const nodes = parseMarkdownToStructure('[:smile:](http://a)', md)
|
|
const ls = links(nodes)
|
|
expect(ls.length).toBe(1)
|
|
// Either emoji child in link, or raw/text contains the shortcode or the emoji char
|
|
expect(hasEmoji(ls[0].children || []) || textIncludes(ls[0], ':smile:') || textIncludes(ls[0], '😄')).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('extra edge cases (tolerant mid-states)', () => {
|
|
describe('table trailing pipes and escaped pipes', () => {
|
|
it('"a | b |" alone is not a table; add separator with trailing pipes to form a table', () => {
|
|
const a = parseMarkdownToStructure('a | b |', md)
|
|
expect(collect(a, 'table').length).toBe(0)
|
|
const b = parseMarkdownToStructure('a | b |\n---|---|', md)
|
|
expect(collect(b, 'table').length > 0).toBe(true)
|
|
})
|
|
|
|
it('escaped pipes do not form a table until a valid separator appears', () => {
|
|
const s1 = parseMarkdownToStructure('a \\| b', md)
|
|
expect(collect(s1, 'table').length).toBe(0)
|
|
const p = collect(s1, 'paragraph')[0] as any
|
|
// Robustly assert paragraph text contains the expected pieces (allowing parser to keep or remove escape)
|
|
// Prefer the helper which serializes nodes and searches for the text pieces
|
|
expect((typeof p !== 'undefined' ? textIncludes(p, 'a') : textIncludes(s1, 'a')) && (typeof p !== 'undefined' ? textIncludes(p, 'b') : textIncludes(s1, 'b'))).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('fenced code blocks with params and blank content', () => {
|
|
it('```js { foo:1 } mid-state: accept fence node (language starts with js) or paragraph fallback', () => {
|
|
const nodes = parseMarkdownToStructure('```js { foo:1 }', md)
|
|
expect(fenceOrParagraph(nodes)).toBe(true)
|
|
const fenceNodes = collect(nodes, 'code_block') as any[]
|
|
const fenceAlt = collect(nodes, 'fence') as any[]
|
|
const hasFence = fenceNodes.length > 0 || fenceAlt.length > 0
|
|
if (hasFence) {
|
|
const f = (fenceNodes[0] || fenceAlt[0]) as any
|
|
// language should be parsed as js when recognized
|
|
if (typeof f?.language === 'string')
|
|
expect(f.language.startsWith('js')).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('``` followed by newline with empty content is a valid mid-state', () => {
|
|
const nodes = parseMarkdownToStructure('```\n', md)
|
|
expect(fenceOrParagraph(nodes)).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('link + emoji variants', () => {
|
|
it('emoji at end of link text with URL unclosed yields loading link or text fallback', () => {
|
|
const nodes = parseMarkdownToStructure('[:smile:x](http://a', md)
|
|
if (hasLoadingLink(nodes)) {
|
|
const ls = links(nodes)
|
|
const l = ls[0]
|
|
expect(!!l.loading).toBe(true)
|
|
expect(textIncludes(l, ':smile:x') || textIncludes(l, 'emoji')).toBe(true)
|
|
}
|
|
else {
|
|
expect(textIncludes(nodes, '[:smile:x](http://a') || textIncludes(nodes, 'emoji')).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('list item with multiple emoji and unclosed link remains a list with paragraph child', () => {
|
|
const nodes = parseMarkdownToStructure('- :rocket: :fire: [Go](http://a', md)
|
|
const list = collect(nodes, 'list')[0] as any
|
|
expect(!!list).toBe(true)
|
|
const para = list.items?.[0]?.children?.find((c: any) => c.type === 'paragraph')
|
|
expect(!!para).toBe(true)
|
|
expect(hasEmoji(para?.children || [])).toBe(true)
|
|
// link may or may not be recognized yet, but should not crash
|
|
const links = para ? para.children.filter((c: any) => c.type === 'link') : []
|
|
expect(links.length >= 0).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('interleaved marking mid-states', () => {
|
|
it('"~~**x" mid-state: tolerate as text or as partially parsed marking without crash', () => {
|
|
const nodes = parseMarkdownToStructure('~~**x', md)
|
|
const ok = textIncludes(nodes, '~~**x')
|
|
|| collect(nodes, 'strikethrough').length >= 1
|
|
|| collect(nodes, 'strong').length >= 1
|
|
expect(ok).toBe(true)
|
|
})
|
|
|
|
it('"**~~x" mid-state: tolerate as text or as partially parsed marking without crash', () => {
|
|
const nodes = parseMarkdownToStructure('**~~x', md)
|
|
const ok = textIncludes(nodes, '**~~x')
|
|
|| collect(nodes, 'strikethrough').length >= 1
|
|
|| collect(nodes, 'strong').length >= 1
|
|
expect(ok).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('blockquote/list/checkbox combined mid-state', () => {
|
|
it('"> - [ ] x" without newline: accept blockquote or paragraph/list fallback', () => {
|
|
const nodes = parseMarkdownToStructure('> - [ ] x', md)
|
|
const ok = hasNode(nodes, 'blockquote')
|
|
|| hasParagraph(nodes)
|
|
|| hasNode(nodes, 'list')
|
|
expect(ok).toBe(true)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('more extras (tolerant mid-states v2)', () => {
|
|
describe('autolink angle-bracket mid-states', () => {
|
|
it('"<http://a" mid-state: allow plain text or early link; no crash', () => {
|
|
const nodes = parseMarkdownToStructure('<http://a', md)
|
|
// Either no link yet or tokenizer emits a provisional link
|
|
const links = collect(nodes, 'link')
|
|
expect(links.length >= 0).toBe(true)
|
|
})
|
|
|
|
it('"<http://a>" finalized autolink: may become a link node or remain text depending on plugins', () => {
|
|
const nodes = parseMarkdownToStructure('<http://a>', md)
|
|
const links = collect(nodes, 'link') as any[]
|
|
if (links.length > 0) {
|
|
// tolerant check for href/url on autolink depending on plugin
|
|
expect(textIncludes(links[0], 'http://a') || String(links[0].href || links[0].url || '').includes('http://a')).toBe(true)
|
|
}
|
|
else {
|
|
// Accept tokenizer keeping as plain text when autolink plugin is not enabled
|
|
expect(textIncludes(nodes, '<http://a>')).toBe(true)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('tilde fenced code blocks', () => {
|
|
it('"~~~" mid-state: accept fence or paragraph fallback', () => {
|
|
const nodes = parseMarkdownToStructure('~~~', md)
|
|
const fence = collect(nodes, 'code_block').length > 0
|
|
|| collect(nodes, 'fence').length > 0
|
|
const para = hasParagraph(nodes)
|
|
expect(fence || para).toBe(true)
|
|
})
|
|
|
|
it('closed tilde fence recognized as code block', () => {
|
|
const nodes = parseMarkdownToStructure('~~~\ncode\n~~~', md)
|
|
const code = collect(nodes, 'code_block').length > 0
|
|
|| collect(nodes, 'fence').length > 0
|
|
expect(code).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('indented code block mid-states', () => {
|
|
it('3 spaces then text is not an indented code block', () => {
|
|
const nodes = parseMarkdownToStructure(' not-code', md)
|
|
const isCode = collect(nodes, 'code_block').length > 0
|
|
expect(isCode).toBe(false)
|
|
expect(hasParagraph(nodes)).toBe(true)
|
|
})
|
|
|
|
it('4 spaces then text becomes an indented code block (or fence-like)', () => {
|
|
const nodes = parseMarkdownToStructure(' code', md)
|
|
const isCode = collect(nodes, 'code_block').length > 0
|
|
|| collect(nodes, 'fence').length > 0
|
|
const para = hasParagraph(nodes)
|
|
expect(isCode || para).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('link title mid-states', () => {
|
|
it('"[x](http://a \"t" mid-state: allow loading link or text fallback', () => {
|
|
const nodes = parseMarkdownToStructure('[x](http://a "t', md)
|
|
const links = collect(nodes, 'link') as any[]
|
|
if (links.length > 0) {
|
|
const l = links[0]
|
|
// may not set loading for title mid-state; just ensure no crash
|
|
expect(!!l).toBe(true)
|
|
}
|
|
else {
|
|
expect(textIncludes(nodes, '[x](http://a "t')).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('"[x](http://a \"t\")" finalized link; title may be present', () => {
|
|
const nodes = parseMarkdownToStructure('[x](http://a "t")', md)
|
|
const links = collect(nodes, 'link') as any[]
|
|
expect(links.length).toBe(1)
|
|
expect(links[0].href).toBe('http://a')
|
|
// title is optional based on tokenizer; accept either
|
|
if (typeof links[0].title === 'string')
|
|
expect(links[0].title).toBe('t')
|
|
})
|
|
})
|
|
|
|
describe('image alt containing emoji shortcode', () => {
|
|
it('" => {
|
|
const nodes = parseMarkdownToStructure('
|
|
const images = collect(nodes, 'image')
|
|
expect(images.length >= 0).toBe(true)
|
|
expect(images.length > 0 || textIncludes(nodes, ').toBe(true)
|
|
})
|
|
|
|
it('"" finalized image; alt may include emoji or raw text', () => {
|
|
const nodes = parseMarkdownToStructure('', md)
|
|
const images = collect(nodes, 'image') as any[]
|
|
expect(images.length).toBe(1)
|
|
expect(images[0].src || images[0].href).toBe('http://img')
|
|
expect(textIncludes(images[0], 'emoji') || textIncludes(images[0], ':smile:')).toBe(true)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('more extras (tolerant mid-states v3)', () => {
|
|
describe('reference definition mid-states', () => {
|
|
it('"[id]:" mid-state: stays text or provisional reference_def; no crash', () => {
|
|
const nodes = parseMarkdownToStructure('[id]:', md)
|
|
// Accept as plain text or any reference-like node
|
|
const ok = textIncludes(nodes, '[id]:') || collect(nodes, 'reference_def').length >= 0
|
|
expect(ok).toBe(true)
|
|
})
|
|
|
|
it('"[id]: http://a" may become a reference definition or remain text', () => {
|
|
const nodes = parseMarkdownToStructure('[id]: http://a', md)
|
|
const defs = collect(nodes, 'reference_def') as any[]
|
|
if (defs.length > 0) {
|
|
// href/src naming may differ
|
|
const href = defs[0].href || defs[0].url || defs[0].src
|
|
// Prefer tolerant structural check; fall back to string include when needed
|
|
expect(textIncludes(defs[0], 'http://a') || String(href || '').includes('http://a')).toBe(true)
|
|
}
|
|
else {
|
|
const hasPara = collect(nodes, 'paragraph').length > 0
|
|
// Accept normalized text or even empty/no-op output depending on plugins
|
|
const ok = hasPara || textIncludes(nodes, 'http://a') || textIncludes(nodes, '[id]') || Array.isArray(nodes)
|
|
expect(ok).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('use-before-def mid-state: "[x][id]\n[id]:" tolerates as text or partial link', () => {
|
|
const nodes = parseMarkdownToStructure('[x][id]\n[id]:', md)
|
|
const links = collect(nodes, 'link')
|
|
const texts = collect(nodes, 'text')
|
|
// Either link is recognized or kept as text until def completes
|
|
expect(links.length >= 0).toBe(true)
|
|
expect(texts.length === 1 || textIncludes(nodes, '[x][id]\n[id]:')).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('custom container/admonition mid-states', () => {
|
|
it('"::: tip" mid-state: accept container/admonition node or paragraph fallback', () => {
|
|
const nodes = parseMarkdownToStructure('::: tip', md)
|
|
const container = collect(nodes, 'container')[0] || collect(nodes, 'admonition')[0]
|
|
const para = hasParagraph(nodes)
|
|
expect(!!container || para).toBe(true)
|
|
})
|
|
|
|
it('"::: tip\ncontent\n:::" closed: container/admonition with paragraph child or paragraph fallback', () => {
|
|
const nodes = parseMarkdownToStructure('::: tip\ncontent\n:::', md)
|
|
const container = (collect(nodes, 'container')[0] || collect(nodes, 'admonition')[0]) as any
|
|
if (container) {
|
|
const hasPara = (container.children || container.items || []).some((c: any) => c?.type === 'paragraph')
|
|
expect(hasPara).toBe(true)
|
|
}
|
|
else {
|
|
// Accept fallback
|
|
expect(hasParagraph(nodes)).toBe(true)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('hTML block mid-states', () => {
|
|
it('"<div" mid-state: accept html_block or paragraph fallback', () => {
|
|
const nodes = parseMarkdownToStructure('<div', md)
|
|
const html = collect(nodes, 'html_block').length > 0 || collect(nodes, 'html').length > 0
|
|
const para = collect(nodes, 'paragraph').length > 0
|
|
expect(html || para || Array.isArray(nodes)).toBe(true)
|
|
})
|
|
|
|
it('"<div>\ntext\n</div>" closed: may be html_block or paragraphs', () => {
|
|
const nodes = parseMarkdownToStructure('<div>\ntext\n</div>', md)
|
|
const html = collect(nodes, 'html_block').length > 0 || collect(nodes, 'html').length > 0
|
|
const para = collect(nodes, 'paragraph').length > 0
|
|
expect(html || para || Array.isArray(nodes)).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('math block mid-states', () => {
|
|
it('"$$" mid-state: accept math_block or paragraph fallback', () => {
|
|
const nodes = parseMarkdownToStructure('$$', md)
|
|
const math = collect(nodes, 'math_block').length > 0 || collect(nodes, 'math').length > 0
|
|
const para = hasParagraph(nodes)
|
|
expect(math || para).toBe(true)
|
|
})
|
|
|
|
it('"$$\na+b\n$$" closed: should be math_block or paragraph fallback (depending on plugin)', () => {
|
|
const nodes = parseMarkdownToStructure('$$\na+b\n$$', md)
|
|
const math = collect(nodes, 'math_block').length > 0 || collect(nodes, 'math').length > 0
|
|
const para = hasParagraph(nodes)
|
|
expect(math || para).toBe(true)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('more extras (tolerant mid-states v4)', () => {
|
|
describe('setext heading transitions', () => {
|
|
it('"Title" stays paragraph; then "Title\n===" becomes heading level 1', () => {
|
|
const a = parseMarkdownToStructure('Title', md)
|
|
expect(hasNode(a, 'heading')).toBeFalsy()
|
|
const b = parseMarkdownToStructure('Title\n===', md)
|
|
const h = collect(b, 'heading')[0] as any
|
|
expect(h).toBeTruthy()
|
|
expect(h.level).toBe(1)
|
|
})
|
|
|
|
it('"Title\n---" becomes heading level 2 (not hr here due to preceding text)', () => {
|
|
const b = parseMarkdownToStructure('Title\n---', md)
|
|
const h = collect(b, 'heading')[0] as any
|
|
expect(h).toBeTruthy()
|
|
expect(h.level).toBe(2)
|
|
})
|
|
})
|
|
|
|
describe('double backtick inline code mid-states', () => {
|
|
it('"``x" yields inline_code or text fallback; "``x``" closes it', () => {
|
|
const a = parseMarkdownToStructure('``x', md)
|
|
const codeA = collect(a, 'inline_code')[0] as any
|
|
const okA = !!codeA || textIncludes(a, '``x')
|
|
expect(okA).toBe(true)
|
|
|
|
const b = parseMarkdownToStructure('``x``', md)
|
|
const codeB = collect(b, 'inline_code')[0] as any
|
|
if (codeB) {
|
|
expect(codeB.code || codeB.raw || '').toContain('x')
|
|
}
|
|
else {
|
|
// accept fallback if tokenizer chooses otherwise
|
|
expect(textIncludes(b, '``x``')).toBe(true)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('escaped punctuation mid-states', () => {
|
|
it('"\\*x" should not crash; parser may keep text or still parse emphasis depending on escapes', () => {
|
|
const nodes = parseMarkdownToStructure('\\*x', md)
|
|
const emph = collect(nodes, 'emphasis').length
|
|
// Accept either: preserved literal *x in text, or emphasis recognized by tokenizer
|
|
const ok = textIncludes(nodes, '*x') || emph >= 1
|
|
expect(ok).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('hTML comments mid-states', () => {
|
|
it('"<!--" mid-state: accept html/html_block or paragraph fallback', () => {
|
|
const a = parseMarkdownToStructure('<!--', md)
|
|
const htmlish = collect(a, 'html_block').length > 0 || collect(a, 'html').length > 0
|
|
const para = collect(a, 'paragraph').length > 0
|
|
expect(htmlish || para || Array.isArray(a)).toBe(true)
|
|
})
|
|
|
|
it('"<!-- c -->" closed: may be html/html_block or paragraph', () => {
|
|
const b = parseMarkdownToStructure('<!-- c -->', md)
|
|
const htmlish = collect(b, 'html_block').length > 0 || collect(b, 'html').length > 0
|
|
const para = collect(b, 'paragraph').length > 0
|
|
expect(htmlish || para || Array.isArray(b)).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('footnote definition mid-states', () => {
|
|
it('"[^1]:" mid-state remains text or provisional def node', () => {
|
|
const a = parseMarkdownToStructure('[^1]:', md)
|
|
const got = collect(a, 'footnote_definition')
|
|
const para = collect(a, 'paragraph').length > 0
|
|
expect(got.length >= 0).toBe(true)
|
|
expect(got.length > 0 || para || textIncludes(a, '^1') || Array.isArray(a)).toBe(true)
|
|
})
|
|
|
|
it('"[^1]: hello" may produce a definition or remain text', () => {
|
|
const b = parseMarkdownToStructure('[^1]: hello', md)
|
|
const got = collect(b, 'footnote_definition') as any[]
|
|
if (got.length > 0) {
|
|
expect(textIncludes(got[0], 'hello')).toBe(true)
|
|
}
|
|
else {
|
|
const para = collect(b, 'paragraph').length > 0
|
|
expect(para || textIncludes(b, 'hello') || textIncludes(b, '^1') || Array.isArray(b)).toBe(true)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('definition list mid-states', () => {
|
|
it('"Term\n:" mid-state tolerates as text or provisional definition list', () => {
|
|
const a = parseMarkdownToStructure('Term\n:', md)
|
|
const defs = collect(a, 'definition_list')
|
|
.concat(collect(a, 'dl'))
|
|
.concat(collect(a, 'definition'))
|
|
expect(defs.length >= 0).toBe(true)
|
|
expect(defs.length > 0 || textIncludes(a, 'Term') || textIncludes(a, '\n:')).toBe(true)
|
|
})
|
|
|
|
it('"Term\n: def" becomes a definition list or acceptable fallback', () => {
|
|
const b = parseMarkdownToStructure('Term\n: def', md)
|
|
const defs = collect(b, 'definition_list')
|
|
.concat(collect(b, 'dl'))
|
|
.concat(collect(b, 'definition'))
|
|
if (defs.length > 0) {
|
|
expect(textIncludes(defs[0], 'def')).toBe(true)
|
|
}
|
|
else {
|
|
// Accept paragraph/list fallback depending on tokenizer config
|
|
const ok = hasParagraph(b) || hasNode(b, 'list')
|
|
expect(ok).toBe(true)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('additional mid-state edge cases (tables/marking/mermaid/emoji-links)', () => {
|
|
it('table cell containing inline code mid-state: should produce a table with inline_code in cell or fall back', () => {
|
|
const nodes = parseMarkdownToStructure('a | `b`\n---|---', md)
|
|
const tables = collect(nodes, 'table')
|
|
if (tables.length > 0) {
|
|
const tbl: any = tables[0]
|
|
// Look for inline_code somewhere in the table structure
|
|
const foundCode = textIncludes(tbl, '`b`') || textIncludes(tbl, 'b')
|
|
expect(foundCode).toBe(true)
|
|
}
|
|
else {
|
|
// Accept tolerant fallback
|
|
expect(textIncludes(nodes, '`b`') || textIncludes(nodes, 'a')).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('highlight containing emphasis: "==*x*==" should tolerate nested parsing', () => {
|
|
const nodes = parseMarkdownToStructure('==*x*==', md)
|
|
// Accept either highlight node containing emphasis, or safe text fallback
|
|
const hil = collect(nodes, 'highlight')
|
|
const em = collect(nodes, 'emphasis')
|
|
const ok = hil.length > 0 || em.length > 0 || textIncludes(nodes, '==*x*==')
|
|
expect(ok).toBe(true)
|
|
})
|
|
|
|
it('mermaid fence with leading spaces/unclosed should not crash', () => {
|
|
const nodes = parseMarkdownToStructure(' ```mermaid\nflowchart LR\nA-->B', md)
|
|
const code = collect(nodes, 'code_block')
|
|
if (code.length > 0) {
|
|
// language may be recognized despite leading spaces
|
|
expect(textIncludes(code[0], 'mermaid') || textIncludes(code[0], 'flowchart')).toBe(true)
|
|
}
|
|
else {
|
|
expect(textIncludes(nodes, 'flowchart') || hasParagraph(nodes)).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('link title containing emoji mid-state: allow either loading link or text with emoji', () => {
|
|
const nodes = parseMarkdownToStructure('[x](http://a "t😁"', md)
|
|
const ls = links(nodes)
|
|
if (ls.length > 0) {
|
|
expect(!!ls[0].loading).toBe(true)
|
|
// allow either the emoji present in raw/title or the 't' character (title stub)
|
|
expect(
|
|
hasEmoji(ls[0].children || [])
|
|
|| textIncludes(ls[0], '😁')
|
|
|| textIncludes(ls[0], 't')
|
|
|| textIncludes(ls[0], 'smile')
|
|
|| textIncludes(nodes, 't')
|
|
|| textIncludes(nodes, '😁'),
|
|
).toBe(true)
|
|
}
|
|
else {
|
|
expect(textIncludes(nodes, 't😁') || textIncludes(nodes, '[x](http://a "t')).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('trailing pipe variants: "a | b |" with separator should form table with empty cell', () => {
|
|
const nodes = parseMarkdownToStructure('a | b |\n---|---|', md)
|
|
const tables = collect(nodes, 'table')
|
|
if (tables.length > 0) {
|
|
// table exists; ensure at least header cells are present
|
|
expect(textIncludes(tables[0], 'a') && textIncludes(tables[0], 'b')).toBe(true)
|
|
}
|
|
else {
|
|
expect(textIncludes(nodes, 'a') && textIncludes(nodes, 'b')).toBe(true)
|
|
}
|
|
})
|
|
})
|
|
})
|
|
})
|