/** * markdown-html splitter spec: the pure lifting of raw-HTML runs out of the * markdown stream (fence-aware), the tag-balance part analysis (unclosed * `
` → wrapper opens, stray closes → pops), the doc-wide reference * definition collection, and the inline-html gate. */ import { describe, expect, it } from 'vitest' import { analyzeHtmlSegment, analyzeMarkdownHtml, collectReferenceDefinitions, splitHtmlBlocks, type HtmlPart, } from '../src/client/markdown-html.ts' /** Structural view of parts: html parts as [html ], opens/closes by kind. */ function shape(parts: readonly HtmlPart[]): string[] { return parts.map((part) => part.kind === 'html' ? `html(${part.html})` : part.kind === 'open' ? `open(${part.tag}${part.attrs.trim() !== '' ? ` ${part.attrs.trim()}` : ''})` : `close(${part.tag})`) } describe('splitHtmlBlocks', () => { it('returns one markdown segment for plain markdown (no HTML)', () => { const text = '# Title\n\nsome **markdown**\n\n- a\n- b\n' expect(splitHtmlBlocks(text)).toEqual([{ kind: 'markdown', text: text.replace(/\n$/, '') }]) }) it('lifts a balanced block-tag run out of the markdown stream', () => { const segments = splitHtmlBlocks([ '# Title', '', '
', ' badge', '
', '', 'tail text', ].join('\n')) expect(segments).toEqual([ { kind: 'markdown', text: '# Title' }, { kind: 'html', text: '
\n badge\n
' }, { kind: 'markdown', text: 'tail text' }, ]) }) it('ends an HTML run at a blank line', () => { const segments = splitHtmlBlocks('

a

\n\nafter') expect(segments).toEqual([ { kind: 'html', text: '

a

' }, { kind: 'markdown', text: 'after' }, ]) }) it('does not lift tag-like lines inside fenced code blocks', () => { const segments = splitHtmlBlocks([ '```html', '
', '

example

', '
', '```', '', '
real
', ].join('\n')) expect(segments).toEqual([ { kind: 'markdown', text: '```html\n
\n

example

\n
\n```' }, { kind: 'html', text: '
real
' }, ]) }) it('does not lift inline-only tags or prose starting with a comparison', () => { const segments = splitHtmlBlocks([ 'a < b and x inline', '
alone on its line', ].join('\n')) expect(segments).toEqual([ { kind: 'markdown', text: 'a < b and x inline\n
alone on its line' }, ]) }) it('treats a close-tag-only line as an HTML run', () => { const segments = splitHtmlBlocks('text\n\n
\n\nmore') expect(segments).toEqual([ { kind: 'markdown', text: 'text' }, { kind: 'html', text: '' }, { kind: 'markdown', text: 'more' }, ]) }) it('accumulates a comment run until the closing marker line', () => { const segments = splitHtmlBlocks('\n\nbody') expect(segments).toEqual([ { kind: 'html', text: '' }, { kind: 'markdown', text: 'body' }, ]) }) it('keeps an unterminated fence as markdown (CommonMark recovery)', () => { const segments = splitHtmlBlocks('```\n
x
') expect(segments).toEqual([{ kind: 'markdown', text: '```\n
x
' }]) }) it('keeps the README details shape as three segments', () => { const segments = splitHtmlBlocks([ '
', '更新', '', '```sh', 'dsh plugin --profile web add x', '```', '', '
', ].join('\n')) expect(segments.map((segment) => segment.kind)).toEqual(['html', 'markdown', 'html']) expect(segments[0]).toEqual({ kind: 'html', text: '
\n更新' }) expect(segments[2]).toEqual({ kind: 'html', text: '
' }) }) }) describe('analyzeHtmlSegment', () => { it('reduces a balanced run to a single html part', () => { expect(shape(analyzeHtmlSegment('
').parts)) .toEqual(['html(
)']) }) it('surfaces an unclosed open tag as a wrapper with its inner html', () => { expect(shape(analyzeHtmlSegment('
\n更新').parts)) .toEqual(['open(details)', 'html(\n更新)']) }) it('surfaces an unmatched close as a pop', () => { expect(shape(analyzeHtmlSegment('
').parts)).toEqual(['close(details)']) }) it('orders mixed leading closes before following html and closes', () => { expect(shape(analyzeHtmlSegment(' mid ').parts)) .toEqual(['close(div)', 'html( mid )', 'close(details)']) }) it('nests multiple unclosed opens outermost-first', () => { expect(shape(analyzeHtmlSegment('
').parts)) .toEqual(['open(div align="center")', 'open(details)']) }) it('keeps attrs of the open token for the wrapper', () => { const parts = analyzeHtmlSegment('
body').parts expect(parts[0]).toEqual({ kind: 'open', tag: 'details', attrs: ' open class="x"' }) }) it('ignores void and self-closing tags for balance', () => { expect(shape(analyzeHtmlSegment('
after').parts)) .toEqual(['html(
after)']) }) it('ignores tags inside comments', () => { expect(shape(analyzeHtmlSegment('').parts)) .toEqual(['html()']) }) it('pops implicitly through a mismatched close', () => { //

is implicitly closed by

: nothing structural remains. expect(shape(analyzeHtmlSegment('

text

').parts)) .toEqual(['html(

text

)']) }) }) describe('collectReferenceDefinitions + analyzeMarkdownHtml', () => { it('collects definitions from markdown runs only, in document order', () => { const info = analyzeMarkdownHtml([ 'intro [link][shared]', '', '
badges
', '', '[shared]: https://example.com', '[img]: ./local.png', ].join('\n')) expect(info.referenceDefinitions).toBe('[shared]: https://example.com\n[img]: ./local.png') expect(collectReferenceDefinitions(info.segments)).toBe(info.referenceDefinitions) }) it('reports hasBlockHtml only when a run was lifted', () => { expect(analyzeMarkdownHtml('# plain\ntext').hasBlockHtml).toBe(false) expect(analyzeMarkdownHtml('
x
').hasBlockHtml).toBe(true) // Inline-only tags: no block html, but the inline gate is on. const inline = analyzeMarkdownHtml('| a | b |\n|---|---|\n| x
y | z |') expect(inline.hasBlockHtml).toBe(false) expect(inline.hasInlineHtml).toBe(true) }) it('gates inline html on tag-like source text', () => { expect(analyzeMarkdownHtml('a < b').hasInlineHtml).toBe(false) expect(analyzeMarkdownHtml('a bold').hasInlineHtml).toBe(true) expect(analyzeMarkdownHtml('plain markdown').hasInlineHtml).toBe(false) }) })