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:
boos
2026-06-22 11:32:57 +08:00
parent d9c78ecc73
commit e0a5f57793
2305 changed files with 449355 additions and 0 deletions
@@ -0,0 +1,41 @@
# Tests — helpers & midstate probes
This short guide explains how to run the mid-typing (midstate) tests and regenerate parser probe output used by the test suite.
Why
- Mid-typing tests exercise the parser's provisional states (unclosed fences, loading links, emoji shortcodes, etc.).
- When Vitest output is not fully available (CI or remote tooling), the probe script writes `test/probes/parse-output.json` so you can inspect the AST node shapes and tune tolerant assertions.
Common commands (zsh / macOS)
# Run the full test suite (CI mode):
CI=1 pnpm test
# Run only the midstate test file (verbose):
CI=1 pnpm vitest run test/markdown-midstates.test.ts --reporter verbose
# Run a named block/describe using grep (fast):
pnpm vitest -t 'additional mid-state edge cases' --reporter verbose
# Regenerate the probe JSON (writes to test/probes/parse-output.json):
node test/probes/parse-probe-write-tmp.cjs
# Inspect the probe output (first 200 lines):
# macOS / zsh
sed -n '1,200p' test/probes/parse-output.json
Test helper notes
- Use `test/utils/midstate-utils.ts` helpers instead of brittle JSON.stringify checks:
- `textIncludes(nodes, stringOrRegExp)` — safe recursive search (supports RegExp and raw strings)
- `collect(nodes, type)` — gather all nodes of a given type from an AST
- `hasLoadingLink(nodes)` and `links(nodes)` — helpers for link mid-states
- Tests are intentionally tolerant: they prefer structured nodes when present but accept plain text fallback (useful when optional plugins are disabled).
If you see a failing midstate test:
1. Run the probe script to capture the parse AST for the failing input.
2. Inspect `test/probes/parse-output.json` to see exactly which node fields exist (e.g. `text`, `raw`, `children`, `loading`).
3. Adjust assertions to use `textIncludes(...)` or `collect(...)` to be tolerant of AST variations.
If you'd like, I can:
- Sweep tests for remaining fragile patterns and replace them conservatively in small batches (I already fixed the most obvious cases).
- Add stricter/gated assertions behind an env var if you want deterministic enforcement in CI.
@@ -0,0 +1,316 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`e2e markdown parsing (fixtures) > parses fixture admonition.md > admonition.md 1`] = `
[
{
"children": [
"paragraph",
],
"firstText": "This is a warning admonition with ",
"type": "admonition",
},
]
`;
exports[`e2e markdown parsing (fixtures) > parses fixture checkbox.md > checkbox.md 1`] = `
[
{
"firstText": undefined,
"items": 2,
"ordered": false,
"type": "list",
},
{
"children": [
"text",
],
"firstText": "Regular list item",
"type": "paragraph",
},
]
`;
exports[`e2e markdown parsing (fixtures) > parses fixture code-diff.md > code-diff.md 1`] = `
[
{
"diff": true,
"firstText": undefined,
"language": "",
"preview": "@@
new line
",
"type": "code_block",
},
{
"diff": false,
"firstText": undefined,
"language": "js",
"preview": "console.log('hello world')
",
"type": "code_block",
},
]
`;
exports[`e2e markdown parsing (fixtures) > parses fixture escaped-brackets.md > escaped-brackets.md 1`] = `
[
{
"children": [
"text",
],
"firstText": "A line with an escaped bracket: [not a link]",
"type": "paragraph",
},
{
"children": [
"text",
],
"firstText": "And a literal backslash and more text.",
"type": "paragraph",
},
]
`;
exports[`e2e markdown parsing (fixtures) > parses fixture fence-with-meta.md > fence-with-meta.md 1`] = `
[
{
"diff": true,
"firstText": undefined,
"language": "js",
"preview": "@@
new
",
"type": "code_block",
},
{
"diff": false,
"firstText": undefined,
"language": "python",
"preview": "# regular code block
print('ok')
",
"type": "code_block",
},
]
`;
exports[`e2e markdown parsing (fixtures) > parses fixture footnotes.md > footnotes.md 1`] = `
[
{
"children": [
"text",
"footnote_reference",
"text",
],
"firstText": "This is a sentence with a footnote",
"type": "paragraph",
},
{
"firstText": "This is the footnote text.",
"items": 0,
"type": "footnote",
},
]
`;
exports[`e2e markdown parsing (fixtures) > parses fixture headings.md > headings.md 1`] = `
[
{
"children": [
"text",
],
"firstText": "Heading 1",
"level": 1,
"type": "heading",
},
{
"children": [
"text",
],
"firstText": "A short paragraph under heading 1.",
"type": "paragraph",
},
{
"children": [
"text",
],
"firstText": "Heading 2",
"level": 2,
"type": "heading",
},
{
"children": [
"text",
"strong",
"text",
"emphasis",
"text",
],
"firstText": "Another paragraph with ",
"type": "paragraph",
},
]
`;
exports[`e2e markdown parsing (fixtures) > parses fixture image-link.md > image-link.md 1`] = `
[
{
"children": [
"text",
"image",
],
"firstText": "Here is an image: ",
"type": "paragraph",
},
{
"children": [
"text",
"link",
],
"firstText": "And a link: ",
"type": "paragraph",
},
]
`;
exports[`e2e markdown parsing (fixtures) > parses fixture math.md > math.md 1`] = `
[
{
"children": [
"text",
],
"firstText": "Inline math (\\alpha + \\beta) and another form (x_i).",
"type": "paragraph",
},
{
"children": undefined,
"firstText": undefined,
"type": "math_block",
},
{
"children": [
"text",
],
"firstText": "A line with parentheses that should not break: (not-math)",
"type": "paragraph",
},
]
`;
exports[`e2e markdown parsing (fixtures) > parses fixture mermaid.md > mermaid.md 1`] = `
[
{
"diff": false,
"firstText": undefined,
"language": "mermaid",
"preview": "graph TD
A-->B
B-->C
",
"type": "code_block",
},
]
`;
exports[`e2e markdown parsing (fixtures) > parses fixture nested-lists-edge.md > nested-lists-edge.md 1`] = `
[
{
"firstText": undefined,
"items": 2,
"ordered": true,
"type": "list",
},
{
"firstText": undefined,
"items": 1,
"ordered": false,
"type": "list",
},
]
`;
exports[`e2e markdown parsing (fixtures) > parses fixture table.md > table.md 1`] = `
[
{
"firstText": undefined,
"rows": 2,
"type": "table",
},
{
"children": [
"text",
],
"firstText": "A paragraph after the table.",
"type": "paragraph",
},
]
`;
exports[`e2e markdown parsing (fixtures) > parses fixture trailing-backticks.md > trailing-backticks.md 1`] = `
[
{
"diff": false,
"firstText": undefined,
"language": "js",
"preview": "const a = 1
",
"type": "code_block",
},
{
"children": [
"text",
],
"firstText": "\`\`",
"type": "paragraph",
},
{
"children": [
"text",
],
"firstText": "Some text after an extra backtick line.",
"type": "paragraph",
},
]
`;
exports[`e2e markdown parsing (fixtures) > parses fixture unclosed-fence.md > unclosed-fence.md 1`] = `
[
{
"diff": false,
"firstText": undefined,
"language": "js",
"preview": "console.log('unclosed fence')
# Following heading that might be part of the fence
",
"type": "code_block",
},
]
`;
exports[`e2e markdown parsing (fixtures) > parses fixture unmatched-brackets.md > unmatched-brackets.md 1`] = `
[
{
"children": [
"text",
],
"firstText": "This line has an opening bracket [ without a closing.",
"type": "paragraph",
},
{
"children": [
"text",
"link",
],
"firstText": "Another line with a link-like ",
"type": "paragraph",
},
{
"children": [
"text",
],
"firstText": "And a normal line.",
"type": "paragraph",
},
]
`;
@@ -0,0 +1,101 @@
# Performance Testing Tools
This directory contains tools and documentation for analyzing the performance of KaTeX Worker-based rendering.
## Files
### Benchmark Tests
- **`katex-worker-vs-direct.test.ts`** - Comprehensive benchmark comparing Worker vs direct rendering
- **`katex-performance-demo.html`** - Interactive visual demo for performance testing
### Documentation
- **`katex-worker-performance-analysis.md`** - Complete guide on measuring performance
- **`docs/zh/katex-cache-analysis.md`** - Quick summary (Chinese)
## Quick Start
### Run Benchmarks
```bash
# Run all performance tests
pnpm test test/benchmark/
# Run specific test
pnpm test test/benchmark/katex-worker-vs-direct.test.ts
# With detailed output
pnpm test test/benchmark/katex-worker-vs-direct.test.ts -- --reporter=verbose
```
### Visual Demo
Open `katex-performance-demo.html` in a browser to see an interactive performance comparison.
### Runtime Monitoring
Add to your application:
```typescript
import { enablePerfMonitoring, getPerfReport } from 'markstream-vue/utils/performance-monitor'
enablePerfMonitoring()
// After some usage...
setTimeout(() => getPerfReport(), 30000)
```
Or use browser console:
```javascript
window.__katexPerfReport()
```
## Key Insights
### Cache is Critical
- Cache hit: **~0.01ms**
- Worker render: **5-50ms**
- **Speedup: 500-5000x** 🚀
### Real-World Performance
- Typical cache hit rate: **70-85%**
- Performance improvement: **3-10x**
- Memory overhead: **~36KB for 200 formulas**
### When Worker + Cache Wins
✅ Complex formulas (>10ms render time)
✅ Multiple formulas per page (>5)
✅ Repeated formulas (cache hit rate >30%)
✅ User interaction matters (scrolling, animations)
## Decision Matrix
| Scenario | Recommendation |
|----------|----------------|
| Complex formulas | ✅ Use Worker + Cache |
| Many formulas per page | ✅ Use Worker + Cache |
| High repetition | ✅✅ Cache is highly effective |
| Simple formulas only | ⚠️ Worker overhead acceptable |
| SSR/Node.js | ⚠️ Fallback to direct render |
## Current Architecture (Best Practice)
```typescript
// 1. Check cache first (<0.01ms)
const cached = cache.get(key)
if (cached)
return cached
// 2. Use Worker (non-blocking)
return renderKaTeXInWorker(content)
.then((html) => {
cache.set(key, html) // Populate cache
return html
})
.catch(() => {
// 3. Fallback to direct render
return katex.renderToString(content)
})
```
**This hybrid approach is optimal!** ✅
@@ -0,0 +1,386 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KaTeX Worker Performance Test</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background: #f5f5f5;
}
h1 { color: #333; }
.controls {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
button {
background: #4CAF50;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
margin-right: 10px;
font-size: 14px;
}
button:hover { background: #45a049; }
button.secondary {
background: #2196F3;
}
button.secondary:hover { background: #0b7dda; }
.results {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-top: 20px;
}
.card {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.card h3 {
margin-top: 0;
color: #333;
border-bottom: 2px solid #4CAF50;
padding-bottom: 10px;
}
.metric {
display: flex;
justify-content: space-between;
margin: 10px 0;
padding: 8px;
background: #f9f9f9;
border-radius: 4px;
}
.metric-label {
font-weight: 500;
color: #666;
}
.metric-value {
font-weight: 700;
color: #333;
}
.success { color: #4CAF50; }
.warning { color: #ff9800; }
.error { color: #f44336; }
.log {
background: white;
padding: 15px;
border-radius: 8px;
margin-top: 20px;
max-height: 300px;
overflow-y: auto;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 12px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.log-entry {
margin: 5px 0;
padding: 5px;
border-left: 3px solid #ccc;
padding-left: 10px;
}
.log-entry.worker { border-left-color: #2196F3; }
.log-entry.direct { border-left-color: #ff9800; }
.log-entry.cache { border-left-color: #4CAF50; }
.progress {
height: 20px;
background: #e0e0e0;
border-radius: 10px;
overflow: hidden;
margin: 10px 0;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #4CAF50, #45a049);
transition: width 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 12px;
font-weight: bold;
}
</style>
</head>
<body>
<h1>🧪 KaTeX Worker Performance Test</h1>
<div class="controls">
<h3>Test Controls</h3>
<button onclick="runBenchmark('simple')">Test Simple Formulas</button>
<button onclick="runBenchmark('complex')">Test Complex Formulas</button>
<button onclick="runBenchmark('mixed')">Test Mixed (Realistic)</button>
<button class="secondary" onclick="clearResults()">Clear Results</button>
<button class="secondary" onclick="exportResults()">Export Data</button>
</div>
<div class="results">
<div class="card">
<h3>⚡ Performance Metrics</h3>
<div class="metric">
<span class="metric-label">Total Renders:</span>
<span class="metric-value" id="total-renders">0</span>
</div>
<div class="metric">
<span class="metric-label">Cache Hits:</span>
<span class="metric-value success" id="cache-hits">0</span>
</div>
<div class="metric">
<span class="metric-label">Cache Hit Rate:</span>
<span class="metric-value" id="cache-rate">0%</span>
</div>
<div class="metric">
<span class="metric-label">Avg Worker Time:</span>
<span class="metric-value" id="worker-time">0ms</span>
</div>
<div class="metric">
<span class="metric-label">Avg Cache Time:</span>
<span class="metric-value success" id="cache-time">0ms</span>
</div>
</div>
<div class="card">
<h3>📊 Comparison</h3>
<div class="metric">
<span class="metric-label">Worker Calls:</span>
<span class="metric-value" id="worker-calls">0</span>
</div>
<div class="metric">
<span class="metric-label">Time Saved:</span>
<span class="metric-value success" id="time-saved">0ms</span>
</div>
<div class="metric">
<span class="metric-label">Speedup:</span>
<span class="metric-value success" id="speedup">1.0x</span>
</div>
<div style="margin-top: 20px;">
<div><strong>Cache Efficiency:</strong></div>
<div class="progress">
<div class="progress-bar" id="cache-progress" style="width: 0%">0%</div>
</div>
</div>
</div>
<div class="card">
<h3>💡 Recommendation</h3>
<div id="recommendation" style="padding: 15px; background: #f0f0f0; border-radius: 4px; margin-top: 10px;">
Run a test to get recommendations...
</div>
</div>
</div>
<div class="log">
<h3>📝 Test Log</h3>
<div id="log-content"></div>
</div>
<script>
// Mock data structures for testing
const mockCache = new Map()
const mockMetrics = []
const testFormulas = {
simple: [
'x = y',
'a + b',
'x',
'y',
'z',
'x = y', // repeat
'a + b', // repeat
],
complex: [
'\\\\sum_{i=1}^{n} i = \\\\frac{n(n+1)}{2}',
'\\\\int_{-\\\\infty}^{\\\\infty} e^{-x^2} dx = \\\\sqrt{\\\\pi}',
'\\\\begin{pmatrix} a & b \\\\\\\\ c & d \\\\end{pmatrix}',
'\\\\frac{d}{dx}\\\\left(\\\\int_{a}^{x} f(t)\\\\,dt\\\\right) = f(x)',
],
mixed: [
'x', 'x', 'x', 'x', 'x', // repeat 5x
'y', 'y', 'y', // repeat 3x
'\\\\sum_{i=1}^{n}', '\\\\sum_{i=1}^{n}', // repeat 2x
'\\\\int f(x) dx',
'\\\\frac{a}{b}',
'E = mc^2', 'E = mc^2', 'E = mc^2', // repeat 3x
]
}
function log(message, type = 'info') {
const logDiv = document.getElementById('log-content')
const entry = document.createElement('div')
entry.className = `log-entry ${type}`
entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`
logDiv.appendChild(entry)
logDiv.scrollTop = logDiv.scrollHeight
}
function simulateRender(formula, useCache = true) {
const cacheKey = formula
// Check cache
if (useCache && mockCache.has(cacheKey)) {
const startTime = performance.now()
mockCache.get(cacheKey) // simulate retrieval
const duration = performance.now() - startTime
mockMetrics.push({
type: 'cache-hit',
duration,
formulaLength: formula.length,
timestamp: Date.now(),
success: true
})
log(`Cache hit for "${formula.substring(0, 20)}..." (${duration.toFixed(3)}ms)`, 'cache')
return duration
}
// Simulate Worker render
const complexity = formula.length + (formula.match(/\\\\/g) || []).length * 10
const baseTime = Math.random() * 2 + complexity * 0.1
const workerOverhead = 1.5
const duration = baseTime + workerOverhead
// Add to cache
if (useCache) {
mockCache.set(cacheKey, `<rendered>${formula}</rendered>`)
}
mockMetrics.push({
type: 'worker',
duration,
formulaLength: formula.length,
timestamp: Date.now(),
success: true
})
log(`Worker render "${formula.substring(0, 20)}..." (${duration.toFixed(2)}ms)`, 'worker')
return duration
}
function calculateStats() {
if (mockMetrics.length === 0) return null
const workerMetrics = mockMetrics.filter(m => m.type === 'worker')
const cacheMetrics = mockMetrics.filter(m => m.type === 'cache-hit')
const totalRenders = mockMetrics.length
const cacheHits = cacheMetrics.length
const cacheRate = (cacheHits / totalRenders * 100).toFixed(1)
const avgWorkerTime = workerMetrics.length > 0
? (workerMetrics.reduce((sum, m) => sum + m.duration, 0) / workerMetrics.length).toFixed(2)
: 0
const avgCacheTime = cacheMetrics.length > 0
? (cacheMetrics.reduce((sum, m) => sum + m.duration, 0) / cacheMetrics.length).toFixed(3)
: 0
const totalTime = mockMetrics.reduce((sum, m) => sum + m.duration, 0)
const estimatedNoCache = totalRenders * avgWorkerTime
const timeSaved = (estimatedNoCache - totalTime).toFixed(2)
const speedup = estimatedNoCache > 0 ? (estimatedNoCache / totalTime).toFixed(1) : 1.0
return {
totalRenders,
cacheHits,
cacheRate,
workerCalls: workerMetrics.length,
avgWorkerTime,
avgCacheTime,
timeSaved,
speedup
}
}
function updateUI() {
const stats = calculateStats()
if (!stats) return
document.getElementById('total-renders').textContent = stats.totalRenders
document.getElementById('cache-hits').textContent = stats.cacheHits
document.getElementById('cache-rate').textContent = `${stats.cacheRate}%`
document.getElementById('worker-calls').textContent = stats.workerCalls
document.getElementById('worker-time').textContent = `${stats.avgWorkerTime}ms`
document.getElementById('cache-time').textContent = `${stats.avgCacheTime}ms`
document.getElementById('time-saved').textContent = `${stats.timeSaved}ms`
document.getElementById('speedup').textContent = `${stats.speedup}x`
// Update progress bar
const progressBar = document.getElementById('cache-progress')
progressBar.style.width = `${stats.cacheRate}%`
progressBar.textContent = `${stats.cacheRate}%`
// Update recommendation
let recommendation = ''
const rate = parseFloat(stats.cacheRate)
const workerTime = parseFloat(stats.avgWorkerTime)
if (rate > 70 && workerTime < 50) {
recommendation = '✅ <strong>Excellent!</strong> Worker + Cache is highly effective. Cache hit rate is great and Worker overhead is acceptable.'
} else if (rate > 50) {
recommendation = '✅ <strong>Good!</strong> Worker + Cache provides solid benefits. Consider keeping this architecture.'
} else if (rate < 30 && workerTime < 5) {
recommendation = '⚠️ <strong>Low cache efficiency.</strong> Formulas are very simple or rarely repeated. Direct rendering might be simpler.'
} else {
recommendation = '✅ <strong>Beneficial.</strong> Worker prevents main thread blocking even with lower cache hit rate.'
}
document.getElementById('recommendation').innerHTML = recommendation
}
async function runBenchmark(type) {
log(`Starting ${type} benchmark...`, 'info')
const formulas = testFormulas[type]
for (let i = 0; i < formulas.length; i++) {
await new Promise(resolve => setTimeout(resolve, 10)) // Simulate async
simulateRender(formulas[i])
updateUI()
}
log(`Benchmark complete! Rendered ${formulas.length} formulas.`, 'info')
updateUI()
}
function clearResults() {
mockCache.clear()
mockMetrics.length = 0
document.getElementById('log-content').innerHTML = ''
updateUI()
log('Results cleared', 'info')
}
function exportResults() {
const stats = calculateStats()
const data = {
stats,
metrics: mockMetrics,
timestamp: new Date().toISOString()
}
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `katex-benchmark-${Date.now()}.json`
a.click()
log('Results exported', 'info')
}
// Initialize
log('Performance test ready. Click a test button to begin.', 'info')
</script>
</body>
</html>
@@ -0,0 +1,313 @@
/**
* Benchmark test: Worker vs Direct KaTeX rendering
*
* This test helps determine if Worker-based rendering has advantages over
* direct main-thread rendering in terms of:
* 1. CPU utilization (non-blocking)
* 2. Memory usage
* 3. Overall throughput
* 4. Responsiveness (time-to-interactive)
*/
import { describe, expect, it } from 'vitest'
// Test formulas of varying complexity
const TEST_FORMULAS = {
simple: 'x = y',
medium: '\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}',
complex: '\\int_{-\\infty}^{\\infty} e^{-x^2} dx = \\sqrt{\\pi}',
veryComplex: `
\\begin{pmatrix}
a_{11} & a_{12} & \\cdots & a_{1n} \\\\
a_{21} & a_{22} & \\cdots & a_{2n} \\\\
\\vdots & \\vdots & \\ddots & \\vdots \\\\
a_{m1} & a_{m2} & \\cdots & a_{mn}
\\end{pmatrix}
`,
}
describe('kaTeX Worker vs Direct Rendering Benchmark', () => {
it('should compare single render performance', async () => {
const katex = await import('katex')
const formula = TEST_FORMULAS.medium
const iterations = 100
// Direct rendering benchmark
const directStart = performance.now()
for (let i = 0; i < iterations; i++) {
katex.renderToString(formula, { throwOnError: false, displayMode: true })
}
const directEnd = performance.now()
const directTime = directEnd - directStart
console.log(`\n📊 Single-threaded Direct Rendering:`)
console.log(` Total: ${directTime.toFixed(2)}ms`)
console.log(` Average per render: ${(directTime / iterations).toFixed(2)}ms`)
console.log(` Throughput: ${(iterations / (directTime / 1000)).toFixed(0)} renders/sec`)
// Note: Worker overhead includes serialization + thread communication
// Expected: Direct is faster for small batches, Worker wins for large batches
expect(directTime).toBeGreaterThan(0)
})
it('should demonstrate main thread blocking issue', async () => {
const katex = await import('katex')
const formula = TEST_FORMULAS.veryComplex
const blockingIterations = 50
// Simulate main thread blocking
const mainThreadStart = performance.now()
let totalBlockingTime = 0
for (let i = 0; i < blockingIterations; i++) {
const renderStart = performance.now()
katex.renderToString(formula, { throwOnError: false, displayMode: true })
const renderEnd = performance.now()
totalBlockingTime += renderEnd - renderStart
}
const mainThreadEnd = performance.now()
const totalTime = mainThreadEnd - mainThreadStart
console.log(`\n⚠️ Main Thread Blocking Analysis:`)
console.log(` Total execution time: ${totalTime.toFixed(2)}ms`)
console.log(` Total blocking time: ${totalBlockingTime.toFixed(2)}ms`)
console.log(` Average blocking per render: ${(totalBlockingTime / blockingIterations).toFixed(2)}ms`)
console.log(` UI unresponsive for: ${totalBlockingTime.toFixed(0)}ms total`)
// Any blocking > 50ms is noticeable to users (60fps = 16.67ms per frame)
const averageBlockingTime = totalBlockingTime / blockingIterations
if (averageBlockingTime > 16.67) {
console.log(` ❌ Average render blocks UI frames (>${16.67}ms)`)
console.log(` ✅ Worker would prevent frame drops`)
}
expect(totalBlockingTime).toBeGreaterThan(0)
})
it('should test Worker overhead vs benefit threshold', async () => {
const katex = await import('katex')
// Measure serialization + communication overhead
const testData = TEST_FORMULAS.medium
const serializedSize = new Blob([testData]).size
const estimatedWorkerOverhead = 1 // ~1-2ms for postMessage roundtrip
// Measure actual render time
const renderStart = performance.now()
katex.renderToString(testData, { throwOnError: false, displayMode: true })
const renderEnd = performance.now()
const renderTime = renderEnd - renderStart
console.log(`\n🎯 Worker Viability Analysis:`)
console.log(` Formula size: ${serializedSize} bytes`)
console.log(` Estimated Worker overhead: ~${estimatedWorkerOverhead}ms`)
console.log(` Actual render time: ${renderTime.toFixed(2)}ms`)
// Rule of thumb: Worker is beneficial when:
// 1. Render time > 10ms (noticeable blocking)
// 2. Render time > 3x Worker overhead
// 3. Many concurrent renders expected
const workerBeneficial = renderTime > 10 && renderTime > estimatedWorkerOverhead * 3
if (workerBeneficial) {
console.log(` ✅ Worker IS beneficial (render time ${renderTime.toFixed(1)}ms > ${estimatedWorkerOverhead * 3}ms threshold)`)
console.log(` Reason: Prevents ${renderTime.toFixed(1)}ms main thread blocking`)
}
else {
console.log(` ⚠️ Worker overhead may not be worth it for this formula`)
console.log(` Consider: Cache hits, batch processing, or complexity threshold`)
}
expect(renderTime).toBeGreaterThan(0)
})
it('should test cache effectiveness', async () => {
const katex = await import('katex')
const formula = TEST_FORMULAS.complex
const cacheSimulation = new Map<string, string>()
// First render (cache miss)
const missStart = performance.now()
const result = katex.renderToString(formula, { throwOnError: false, displayMode: true })
const missEnd = performance.now()
const missTime = missEnd - missStart
// Populate cache
cacheSimulation.set(formula, result)
// Cache hit (no Worker call needed)
const hitStart = performance.now()
const _cached = cacheSimulation.get(formula)
const hitEnd = performance.now()
const hitTime = hitEnd - hitStart
console.log(`\n💾 Cache Effectiveness:`)
console.log(` Cache miss: ${missTime.toFixed(3)}ms (Worker call + render)`)
console.log(` Cache hit: ${hitTime.toFixed(3)}ms (instant return)`)
console.log(` Speedup: ${(missTime / hitTime).toFixed(0)}x faster`)
console.log(` \n 💡 This is why Worker cache is CRITICAL!`)
// Cache should be orders of magnitude faster
expect(hitTime).toBeLessThan(missTime / 100)
})
it('should simulate realistic usage patterns', async () => {
const katex = await import('katex')
// Simulate a markdown document with repeated formulas.
// The base list is intentionally small enough to resemble a real message,
// but we repeat it during the benchmark so the timing signal stays larger
// than sub-millisecond JIT / scheduler noise on fast machines.
const baseDocument = [
TEST_FORMULAS.simple, // repeated 10x
TEST_FORMULAS.simple,
TEST_FORMULAS.simple,
TEST_FORMULAS.simple,
TEST_FORMULAS.simple,
TEST_FORMULAS.simple,
TEST_FORMULAS.simple,
TEST_FORMULAS.simple,
TEST_FORMULAS.simple,
TEST_FORMULAS.simple,
TEST_FORMULAS.medium, // repeated 5x
TEST_FORMULAS.medium,
TEST_FORMULAS.medium,
TEST_FORMULAS.medium,
TEST_FORMULAS.medium,
TEST_FORMULAS.complex, // unique
TEST_FORMULAS.veryComplex, // unique
]
const document = Array.from({ length: 10 }, () => baseDocument).flat()
// Warm up KaTeX before measuring. Without this, the benchmark can become
// flaky because module/JIT startup dominates these very short code paths.
for (const formula of Object.values(TEST_FORMULAS))
katex.renderToString(formula, { throwOnError: false, displayMode: true })
// Without cache (naive approach)
const noCacheStart = performance.now()
for (const formula of document) {
katex.renderToString(formula, { throwOnError: false, displayMode: true })
}
const noCacheEnd = performance.now()
const noCacheTime = noCacheEnd - noCacheStart
// With cache (Worker client behavior)
const cache = new Map<string, string>()
const withCacheStart = performance.now()
let cacheMisses = 0
let cacheHits = 0
for (const formula of document) {
if (cache.has(formula)) {
cacheHits++
cache.get(formula) // instant
}
else {
cacheMisses++
const result = katex.renderToString(formula, { throwOnError: false, displayMode: true })
cache.set(formula, result)
}
}
const withCacheEnd = performance.now()
const withCacheTime = withCacheEnd - withCacheStart
console.log(`\n📄 Realistic Document Rendering (${document.length} formulas):`)
console.log(` Without cache: ${noCacheTime.toFixed(2)}ms`)
console.log(` With cache: ${withCacheTime.toFixed(2)}ms`)
console.log(` Speedup: ${(noCacheTime / withCacheTime).toFixed(1)}x`)
console.log(` Cache stats:`)
console.log(` - Hits: ${cacheHits} (${(cacheHits / document.length * 100).toFixed(1)}%)`)
console.log(` - Misses: ${cacheMisses} (${(cacheMisses / document.length * 100).toFixed(1)}%)`)
console.log(` \n 💡 Real documents have ${(cacheHits / document.length * 100).toFixed(0)}% cache hit rate!`)
expect(withCacheTime).toBeLessThan(noCacheTime)
expect(cacheHits).toBeGreaterThan(cacheMisses)
})
it('should measure memory footprint', async () => {
const katex = await import('katex')
const formulas = Object.values(TEST_FORMULAS)
const cache = new Map<string, string>()
let totalInputSize = 0
let totalOutputSize = 0
for (const formula of formulas) {
const inputSize = new Blob([formula]).size
const output = katex.renderToString(formula, { throwOnError: false, displayMode: true })
const outputSize = new Blob([output]).size
totalInputSize += inputSize
totalOutputSize += outputSize
cache.set(formula, output)
}
const expansionRatio = totalOutputSize / totalInputSize
const cacheOverhead = totalOutputSize - totalInputSize
const estimatedCacheSize200 = (totalOutputSize / formulas.length) * 200 // CACHE_MAX
console.log(`\n💾 Memory Footprint Analysis:`)
console.log(` Input (LaTeX): ${totalInputSize} bytes`)
console.log(` Output (HTML): ${totalOutputSize} bytes`)
console.log(` Expansion ratio: ${expansionRatio.toFixed(1)}x`)
console.log(` Cache overhead per entry: ${(cacheOverhead / formulas.length).toFixed(0)} bytes`)
console.log(` Estimated cache size (200 entries): ${(estimatedCacheSize200 / 1024).toFixed(1)} KB`)
console.log(` \n 💡 Memory cost is MINIMAL (~${(estimatedCacheSize200 / 1024).toFixed(0)}KB for 200 formulas)`)
// Allow a small overhead margin for HTML/string object size and environment variance
// Expect under ~1.2MB for 200 entries to keep memory impact low
expect(estimatedCacheSize200).toBeLessThanOrEqual(1.2 * 1024 * 1024)
})
})
describe('worker Decision Matrix', () => {
it('should provide recommendation based on usage patterns', () => {
console.log(`\n
╔═══════════════════════════════════════════════════════════════════════════╗
║ WORKER vs DIRECT RENDERING DECISION ║
╠═══════════════════════════════════════════════════════════════════════════╣
║ ║
║ ✅ USE WORKER when: ║
║ • Complex formulas (render time > 10ms) ║
║ • Multiple formulas per page (>5) ║
║ • User interaction matters (prevent UI blocking) ║
║ • Document may scroll/re-render frequently ║
║ • High cache hit rate expected (repeated formulas) ║
║ ║
║ ⚠️ CONSIDER DIRECT when: ║
║ • Only simple formulas (render time < 5ms) ║
║ • Single formula per page ║
║ • SSR/Node.js environment (no Workers) ║
║ • Bundle size is critical concern ║
║ ║
║ 🎯 HYBRID APPROACH (CURRENT): ║
║ ✅ Try Worker first (with cache) ║
║ ✅ Fallback to direct render if Worker fails ║
║ ✅ Cache in client to benefit both paths ║
║ ✅ Best of both worlds! ║
║ ║
║ 📊 PERFORMANCE CHARACTERISTICS: ║
║ • Worker overhead: ~1-2ms per call ║
║ • Cache hit time: <0.01ms ║
║ • Medium formula: ~5-15ms render ║
║ • Complex formula: ~20-50ms render ║
║ • Cache memory: ~10-50KB for 200 entries ║
║ ║
║ 💡 KEY INSIGHT: ║
║ Worker + Cache eliminates 99% of blocking time! ║
║ Without cache, Worker overhead outweighs benefit for simple formulas ║
║ ║
╚═══════════════════════════════════════════════════════════════════════════╝
`)
expect(true).toBe(true)
})
})
@@ -0,0 +1,131 @@
/**
* Example: How to monitor KaTeX Worker performance in your application
*
* NOTE: This is a reference/example file, not meant to be executed directly.
* Copy relevant snippets into your application code.
*/
import { createApp } from 'vue'
import { enablePerfMonitoring, getPerfReport, perfMonitor } from '../../src/utils/performance-monitor'
// Example 1: Enable monitoring on app start
// Placeholder root component for example purposes
const YourApp = {} as any
const app = createApp(YourApp)
// Enable performance monitoring in development
const __isDev = typeof import.meta !== 'undefined' && (import.meta as any)?.env?.DEV
if (__isDev) {
enablePerfMonitoring()
// Get report after 30 seconds
setTimeout(() => {
const report = getPerfReport()
console.table(report)
}, 30000)
}
app.mount('#app')
// Example 2: Conditional monitoring based on query param
const url = new URLSearchParams(window.location.search)
if (url.get('perf') === '1') {
enablePerfMonitoring()
// Make report available in console
; (window as any).getReport = getPerfReport
console.log('📊 Performance monitoring enabled. Type getReport() to see stats.')
}
// Example 4: Export data for analysis
function exportPerformanceData() {
const data = perfMonitor.exportMetrics()
// Send to analytics service
fetch('/api/analytics', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
// Or download locally
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `katex-perf-${Date.now()}.json`
a.click()
}
// Example 5: Automated performance regression testing
async function runPerformanceTest() {
perfMonitor.enable()
perfMonitor.reset()
// Render test formulas
const stats = perfMonitor.getStats()
// Assertions
const cacheHitRate = Number.parseFloat(String(stats.cacheHitRate))
const avgWorkerTime = Number.parseFloat(String(stats.averageWorkerTime))
if (cacheHitRate < 60) {
console.warn('⚠️ Cache hit rate below 60%:', cacheHitRate)
}
if (avgWorkerTime > 100) {
console.warn('⚠️ Average worker time too high:', avgWorkerTime)
}
console.log('✅ Performance test passed:', stats.recommendation)
perfMonitor.disable()
return stats
}
// Example 6: Real-time monitoring dashboard (in DevTools)
function setupRealtimeMonitoring() {
enablePerfMonitoring()
// Update stats every 5 seconds
setInterval(() => {
const stats = perfMonitor.getStats()
console.clear()
console.log('📊 KaTeX Performance (last 5s)')
console.table({
'Total Renders': stats.totalRenders,
'Cache Hit Rate': `${stats.cacheHitRate}%`,
'Avg Worker Time': `${stats.averageWorkerTime}ms`,
'Avg Cache Hit': `${stats.averageCacheHitTime}ms`,
'Time Saved': `${stats.workerSavings}ms`,
})
console.log(stats.recommendation)
}, 5000)
}
// Browser console utilities
if (typeof window !== 'undefined') {
; (window as any).__katexPerf = {
enable: () => enablePerfMonitoring(),
getReport: () => getPerfReport(),
export: () => exportPerformanceData(),
test: () => runPerformanceTest(),
monitor: () => setupRealtimeMonitoring(),
}
console.log(`
📊 KaTeX Performance Monitoring Available!
Commands:
__katexPerf.enable() - Enable monitoring
__katexPerf.getReport() - Get current report
__katexPerf.export() - Export data as JSON
__katexPerf.test() - Run performance test
__katexPerf.monitor() - Real-time monitoring
Or use:
window.__katexPerfReport()
`)
}
@@ -0,0 +1,28 @@
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
import { describe, expect, it } from 'vitest'
describe('blockquote regressions', () => {
const md = getMarkdown('blockquote-regressions')
it('keeps tables nested inside blockquotes', () => {
const markdown = `> PCB描述如下:
>
> | 料号 | 品名 | 规格 |
> | -- | -- | -- |
> | 120300-5420-01 | PCB | 临时物料-枪机项目-型号:507AA_SENSOR_V10-单板尺寸:27.33*39.22*1.0mm-4层-FR4-黑油-拼板-4*4-拼版尺寸:129.32*162.88*1.0mm-0SP-TG>=150℃ MOT>=130℃-环保测试(RoHS2.0)-五株 [1]`
const nodes = parseMarkdownToStructure(markdown, md)
expect(nodes.length).toBe(1)
const blockquote: any = nodes[0]
expect(blockquote.type).toBe('blockquote')
const hasParagraph = blockquote.children?.some((child: any) => child.type === 'paragraph')
expect(hasParagraph).toBe(true)
const tableNode: any = blockquote.children?.find((child: any) => child.type === 'table')
expect(tableNode).toBeTruthy()
expect(tableNode.rows?.[0]?.cells?.length).toBe(3)
const firstCellText = tableNode.rows?.[0]?.cells?.[0]?.children?.[0]?.content || ''
expect(firstCellText).toContain('120300-5420-01')
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,149 @@
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
import { describe, expect, it } from 'vitest'
describe('custom html blocks should interrupt paragraphs', () => {
it('does not merge a custom html block into the previous paragraph', () => {
const markdown = `
<TaskPlan>
step 1
</TaskPlan>
测试案例
<RadioBtn>
<title>组织结构列表</title>
<content>少时诵诗书</content>
</RadioBtn>
`
const tags = ['TaskPlan', 'RadioBtn']
const md = getMarkdown('custom-html-paragraph-interrupt', { customHtmlTags: tags })
const nodes = parseMarkdownToStructure(markdown, md, { customHtmlTags: tags, final: true }) as any[]
expect(nodes[0]?.type).toBe('taskplan')
expect(nodes[1]?.type).toBe('paragraph')
expect(nodes[1]?.raw).toBe('测试案例')
expect(nodes[2]?.type).toBe('radiobtn')
})
it('handles indented inner lines inside the custom html block', () => {
const markdown = `
<TaskPlan>
step 1
</TaskPlan>
测试案例
<RadioBtn>
<title>组织结构列表</title>
<content>少时诵诗书</content>
</RadioBtn>
`
const tags = ['TaskPlan', 'RadioBtn']
const md = getMarkdown('custom-html-paragraph-interrupt-indented', { customHtmlTags: tags })
const nodes = parseMarkdownToStructure(markdown, md, { customHtmlTags: tags, final: true }) as any[]
expect(nodes[0]?.type).toBe('taskplan')
expect(nodes[1]?.type).toBe('paragraph')
expect(nodes[1]?.raw).toBe('测试案例')
expect(nodes[2]?.type).toBe('radiobtn')
})
it('does not insert blank lines inside a custom html block', () => {
const markdown = `
<Outer>
line
<Inner>
x
</Inner>
</Outer>
`
const tags = ['Outer', 'Inner']
const md = getMarkdown('custom-html-paragraph-interrupt-nested', { customHtmlTags: tags })
const nodes = parseMarkdownToStructure(markdown, md, { customHtmlTags: tags, final: true }) as any[]
expect(nodes).toHaveLength(1)
expect(nodes[0]?.type).toBe('outer')
expect(String(nodes[0]?.content ?? '')).toContain('line\n<Inner>')
expect(String(nodes[0]?.content ?? '')).not.toContain('line\n\n<Inner>')
})
it('handles custom html blocks inside a blockquote', () => {
const markdown = `
> 测试案例
> <RadioBtn>
> <title>组织结构列表</title>
> <content>少时诵诗书</content>
> </RadioBtn>
`
const tags = ['RadioBtn']
const md = getMarkdown('custom-html-paragraph-interrupt-blockquote', { customHtmlTags: tags })
const nodes = parseMarkdownToStructure(markdown, md, { customHtmlTags: tags, final: true }) as any[]
expect(nodes).toHaveLength(1)
expect(nodes[0]?.type).toBe('blockquote')
expect(nodes[0]?.children?.[0]?.type).toBe('paragraph')
expect(nodes[0]?.children?.[0]?.raw).toBe('测试案例')
expect(nodes[0]?.children?.[1]?.type).toBe('radiobtn')
})
it('handles custom html blocks inside a list item', () => {
const markdown = `
- 测试案例
<RadioBtn>
<title>组织结构列表</title>
<content>少时诵诗书</content>
</RadioBtn>
`
const tags = ['RadioBtn']
const md = getMarkdown('custom-html-paragraph-interrupt-list', { customHtmlTags: tags })
const nodes = parseMarkdownToStructure(markdown, md, { customHtmlTags: tags, final: true }) as any[]
expect(nodes).toHaveLength(1)
expect(nodes[0]?.type).toBe('list')
expect(nodes[0]?.items?.[0]?.type).toBe('list_item')
expect(nodes[0]?.items?.[0]?.children?.[0]?.type).toBe('paragraph')
expect(nodes[0]?.items?.[0]?.children?.[0]?.raw).toBe('测试案例')
expect(nodes[0]?.items?.[0]?.children?.[1]?.type).toBe('radiobtn')
})
it('does not insert blank lines inside indented code blocks', () => {
const markdown = `
console.log('x')
<RadioBtn>
<title>组织结构列表</title>
</RadioBtn>
`
const tags = ['RadioBtn']
const md = getMarkdown('custom-html-paragraph-interrupt-indented-code', { customHtmlTags: tags })
const nodes = parseMarkdownToStructure(markdown, md, { customHtmlTags: tags, final: true }) as any[]
expect(nodes[0]?.type).toBe('code_block')
expect(String(nodes[0]?.code ?? '')).toContain('console.log(\'x\')\n<RadioBtn>')
expect(String(nodes[0]?.code ?? '')).not.toContain('console.log(\'x\')\n\n<RadioBtn>')
})
it('keeps a trailing paragraph after an html line followed by a custom block with inner lists', () => {
const markdown = `<stepstart>开始解答(已查阅9550字资料)</stepstart>
<thinking>
我需要回答用户关于"xxx公司旗下的上市公司有哪些?"的问题。
首先,我需要检查提供的知识库信息和公司基本信息,看看是否包含xxx公司旗下的上市公司信息。
从提供的知识库信息中,我看到以下相关内容:
1. "x公司有限公司成立于1988年,由原xxx公司与xx集团重组而成,是xx省属重点企业。...旗下拥有近19家企业,员工总数约1万人,并控股xxxx公司公司、xxx公司、xxx公司等x家上市公司。"
</thinking>
xxx公司旗下的上市公司有:`
for (const tags of [['thinking'], ['stepstart', 'thinking']]) {
const md = getMarkdown(`custom-html-paragraph-interrupt-adjacent-complete-tag-${tags.join('-')}`, { customHtmlTags: tags })
const nodes = parseMarkdownToStructure(markdown, md, { customHtmlTags: tags, final: true }) as any[]
expect(nodes.map(node => node?.type)).toEqual(['paragraph', 'thinking', 'paragraph'])
expect(['stepstart', 'html_block', 'html_inline']).toContain(nodes[0]?.children?.[0]?.type)
expect(String(nodes[1]?.content ?? '')).toContain('1. "x公司有限公司成立于1988年')
expect(nodes[2]?.raw).toBe('xxx公司旗下的上市公司有:')
}
})
})
@@ -0,0 +1,45 @@
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
import { describe, expect, it } from 'vitest'
function normalizeText(input: string) {
return input.replace(/\s+/g, ' ').trim()
}
describe('custom html_block -> custom nodes repeat', () => {
it('parses repeated custom block tags with distinct content', () => {
const markdown = `<new-question>A</new-question>
<sub-question>
B
</sub-question>
<sub-question>
C
</sub-question>`
const tags = ['thinking', 'new-question', 'sub-question']
const md = getMarkdown('custom-html-block-repeat', { customHtmlTags: tags })
const nodes = parseMarkdownToStructure(markdown, md, { customHtmlTags: tags }) as any[]
// markdown-it tokenizes the first two lines as a paragraph (inline HTML), and the
// second <sub-question> as an html_block. The parser must NOT "re-extract" the
// earlier <sub-question> (B) when converting the later html_block (C).
expect(nodes).toHaveLength(2)
const paragraph = nodes[0]
expect(paragraph.type).toBe('paragraph')
const newQ = paragraph.children?.find((n: any) => n.type === 'new-question')
const subInline = paragraph.children?.find((n: any) => n.type === 'sub-question')
expect(newQ).toBeTruthy()
expect(normalizeText(String(newQ.content))).toBe('A')
expect(subInline).toBeTruthy()
expect(normalizeText(String(subInline.content))).toBe('B')
const subBlock = nodes[1]
expect(subBlock.type).toBe('sub-question')
expect(normalizeText(String(subBlock.content))).toBe('C')
})
})
@@ -0,0 +1,45 @@
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
import { describe, expect, it } from 'vitest'
describe('custom html close followed by block markers (same line)', () => {
const tags = ['think']
const md = getMarkdown('custom-html-close-followed-by-block-markers', { customHtmlTags: tags })
const prefix = '<think>t</think>'
type Case
= | { label: string, suffix: string, expectedType: string }
| { label: string, markdown: string, expectedType: string }
const cases: Case[] = [
{ label: 'heading', suffix: '## h', expectedType: 'heading' },
{ label: 'bullet list', suffix: '- a', expectedType: 'list' },
{ label: 'ordered list', suffix: '1. a', expectedType: 'list' },
{ label: 'blockquote', suffix: '> q', expectedType: 'blockquote' },
{ label: 'fence', suffix: '```js\nx\n```', expectedType: 'code_block' },
{ label: 'table', suffix: '| a | b |\n| - | - |\n| 1 | 2 |', expectedType: 'table' },
{ label: 'thematic break', suffix: '---', expectedType: 'thematic_break' },
{ label: 'admonition', suffix: '::: tip\nx\n:::', expectedType: 'admonition' },
{ label: 'math block', suffix: '$$\n1+1\n$$', expectedType: 'math_block' },
]
// Footnote definitions only become a structured "footnote" node when there is
// a corresponding reference in the document.
cases.push({
label: 'footnote',
markdown: `A[^1].\n\n${prefix}[^1]: x`,
expectedType: 'footnote',
})
const normalizedCases = cases.map((c) => {
if ('markdown' in c)
return c
return { label: c.label, markdown: `${prefix}${c.suffix}`, expectedType: c.expectedType }
})
for (const c of normalizedCases) {
it(`splits boundary for ${c.label}`, () => {
const nodes = parseMarkdownToStructure(c.markdown, md, { customHtmlTags: tags, final: true }) as any[]
expect(nodes.some(n => n?.type === c.expectedType)).toBe(true)
})
}
})
@@ -0,0 +1,24 @@
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
import { describe, expect, it } from 'vitest'
describe('custom html close followed by heading marker (same line)', () => {
const md = getMarkdown('custom-html-close-heading-same-line', { customHtmlTags: ['think'] })
it('parses the trailing "##" as a heading node (not plain text)', () => {
const markdown = `<think>让我思考一下</think>## 我是潮位站潮位预报智能体,\n专门负责为您提供镇海潮位站的潮位预报服务。`
const nodes = parseMarkdownToStructure(markdown, md, { customHtmlTags: ['think'], final: true }) as any[]
// The custom tag can be represented as a root node (html_block) or as an inline
// custom node wrapped in a paragraph depending on markdown-it tokenization.
const hasThinkRoot = nodes[0]?.type === 'think'
const hasThinkInline = nodes[0]?.type === 'paragraph'
&& Array.isArray(nodes[0]?.children)
&& nodes[0].children.some((c: any) => c?.type === 'think' && c?.loading === false)
expect(hasThinkRoot || hasThinkInline).toBe(true)
expect(nodes[1].type).toBe('heading')
expect(nodes[1].level).toBe(2)
expect(nodes[1].text).toContain('我是潮位站潮位预报智能体')
})
})
@@ -0,0 +1,60 @@
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
import { describe, expect, it } from 'vitest'
describe('custom html close same-line markers in inline contexts', () => {
const tags = ['my_component']
const md = getMarkdown('custom-html-close-inline-context-regression', { customHtmlTags: tags })
const markerCases = [
{ label: 'heading marker', suffix: '## h' },
{ label: 'bullet marker', suffix: '- item' },
{ label: 'ordered marker', suffix: '1. item' },
{ label: 'blockquote marker', suffix: '> quote' },
{ label: 'admonition marker', suffix: '::: tip' },
{ label: 'math marker', suffix: '$$x' },
] as const
it.each(markerCases)(
'does not split paragraph when custom tag is inline and followed by $label',
({ suffix }) => {
const markdown = `Text <my_component label="x"></my_component>${suffix}`
const nodes = parseMarkdownToStructure(markdown, md, { customHtmlTags: tags, final: true }) as any[]
expect(nodes).toHaveLength(1)
expect(nodes[0]?.type).toBe('paragraph')
},
)
it.each(markerCases)(
'does not split list item when custom tag is inline and followed by $label',
({ suffix }) => {
const markdown = `- Text <my_component label="x"></my_component>${suffix}`
const nodes = parseMarkdownToStructure(markdown, md, { customHtmlTags: tags, final: true }) as any[]
expect(nodes).toHaveLength(1)
expect(nodes[0]?.type).toBe('list')
expect(nodes[0]?.items).toHaveLength(1)
},
)
it.each(markerCases)(
'does not split blockquote line when custom tag is inline and followed by $label',
({ suffix }) => {
const markdown = `> Text <my_component label="x"></my_component>${suffix}`
const nodes = parseMarkdownToStructure(markdown, md, { customHtmlTags: tags, final: true }) as any[]
expect(nodes).toHaveLength(1)
expect(nodes[0]?.type).toBe('blockquote')
const inner = nodes[0]?.children ?? []
expect(inner).toHaveLength(1)
expect(inner[0]?.type).toBe('paragraph')
},
)
it('still splits when custom block starts at line start', () => {
const markdown = '<my_component>t</my_component>## h'
const nodes = parseMarkdownToStructure(markdown, md, { customHtmlTags: tags, final: true }) as any[]
expect(nodes.map(n => n.type)).toEqual(['paragraph', 'heading'])
})
})
@@ -0,0 +1,40 @@
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
import { describe, expect, it } from 'vitest'
describe('custom html block followed by inline text (same line)', () => {
const md = getMarkdown('custom-html-inline-after-close', { customHtmlTags: ['thinking'] })
const prefix = `<thinking>inside\n\nmore</thinking>我是`
it('wraps trailing inline tokens into a paragraph (no stray root-level inline nodes)', () => {
const markdown = `${prefix}**潮位站潮位预报智能体**,专门负责为您提供镇海潮位站的潮位预报服务。`
const nodes = parseMarkdownToStructure(markdown, md, { customHtmlTags: ['thinking'] }) as any[]
expect(nodes[0].type).toBe('thinking')
expect(nodes[1].type).toBe('paragraph')
expect(nodes[1].children?.map((n: any) => n.type)).toEqual(['text', 'strong', 'text'])
expect(nodes[1].children?.[0]?.content).toBe('我是')
expect(nodes[1].children?.[1]?.children?.[0]?.content).toBe('潮位站潮位预报智能体')
})
it('does not reintroduce the newline for other inline forms', () => {
const cases: Array<{ label: string, suffix: string, mustContainType: string }> = [
{ label: 'plain text', suffix: '普通文本。', mustContainType: 'text' },
{ label: 'emphasis', suffix: '*x*。', mustContainType: 'emphasis' },
{ label: 'strong+em', suffix: '***xx***。', mustContainType: 'strong' },
{ label: 'inline code', suffix: '`code`。', mustContainType: 'inline_code' },
{ label: 'link', suffix: '[a](https://example.com)。', mustContainType: 'link' },
{ label: 'strikethrough', suffix: '~~del~~。', mustContainType: 'strikethrough' },
]
for (const c of cases) {
const nodes = parseMarkdownToStructure(`${prefix}${c.suffix}`, md, { customHtmlTags: ['thinking'] }) as any[]
expect(nodes[0].type, c.label).toBe('thinking')
expect(nodes[1].type, c.label).toBe('paragraph')
expect(nodes.length, c.label).toBe(2)
const childTypes = (nodes[1].children ?? []).map((n: any) => n?.type).filter(Boolean)
expect(childTypes, c.label).toContain(c.mustContainType)
}
})
})
@@ -0,0 +1,122 @@
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
import { describe, expect, it } from 'vitest'
describe('custom html nested same-tag regression', () => {
it('keeps the outer custom tag intact when the same tag is nested inside it', () => {
const markdown = `<think>outer start
<think>inner done</think>
outer end</think>`
const tags = ['think']
const md = getMarkdown('custom-html-nested-same-tag-regression', { customHtmlTags: tags })
const nodes = parseMarkdownToStructure(markdown, md, { customHtmlTags: tags, final: true }) as any[]
expect(nodes).toHaveLength(1)
expect(nodes[0]?.type).toBe('think')
expect(nodes[0]?.loading).toBe(false)
expect(String(nodes[0]?.content ?? '')).toBe('outer start\n\n<think>inner done</think>\n\nouter end')
expect(String(nodes[0]?.raw ?? '')).toContain('</think>')
})
it('keeps an unknown html-like tag as one html_block even without customHtmlTags', () => {
const markdown = `<think>
嗯,用户让我用50字介绍Golang的优缺点。首先,我需要明确Golang是什么,它又被称为Go,是由Google开发的一种静态类型编程语言。我得想它的主要优势和劣势。
优点方面,Golang简单直接,这可能是因为语法简洁,容易学习。高性能也是一个重点,因为它编译成机器码,运行速度快。另外,Golang有内置的并发支持,比如goroutine和channel,这对于处理多任务非常方便,同时还自带了垃圾回收,减少了内存管理的负担。
缺点的话,语法虽然简单,但功能相对有限,可能不适合需要复杂表达式的场景。另外,对于一些高级特性,比如泛型,直到1.18才引入,之前的版本可能不够完善。此外,第三方库的数量和成熟度可能没有其他语言如Python或Java丰富,这对某些项目来说可能是个限制。
</think>`
const md = getMarkdown('custom-html-unknown-block-regression')
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
expect(nodes).toHaveLength(1)
expect(nodes[0]?.type).toBe('html_block')
expect(nodes[0]?.loading).toBe(false)
expect(String(nodes[0]?.content ?? '')).toContain('优点方面')
expect(String(nodes[0]?.content ?? '')).toContain('缺点的话')
expect(String(nodes[0]?.content ?? '')).toContain('</think>')
})
it('keeps the pancreatic-cancer think block as one html_block without customHtmlTags', () => {
const markdown = `<think>
胰腺癌是一种恶性程度很高的消化系统肿瘤,早期症状隐匿,常见表现包括腹痛、黄疸、消瘦和食欲下降。由于发现时多已属中晚期,治疗难度较大,预后相对较差。
目前主要治疗方式包括手术、化疗、放疗及靶向/免疫治疗,但具体方案需结合分期和患者情况综合判断。
</think>`
for (const final of [false, true]) {
const md = getMarkdown(`custom-html-pancreatic-unknown-${final}`)
const nodes = parseMarkdownToStructure(markdown, md, { final }) as any[]
expect(nodes).toHaveLength(1)
expect(nodes[0]?.type).toBe('html_block')
expect(nodes[0]?.tag).toBe('think')
expect(nodes[0]?.loading).toBe(false)
expect(String(nodes[0]?.content ?? '')).toContain('胰腺癌是一种恶性程度很高的消化系统肿瘤')
expect(String(nodes[0]?.content ?? '')).toContain('目前主要治疗方式包括手术')
expect(String(nodes[0]?.content ?? '')).toContain('</think>')
}
})
it('forces loading=false at final=true for an unclosed pancreatic-cancer think block without customHtmlTags', () => {
const markdown = `<think>
胰腺癌是一种恶性程度很高的消化系统肿瘤,早期症状隐匿,常见表现包括腹痛、黄疸、消瘦和食欲下降。
目前主要治疗方式包括手术、化疗、放疗及靶向/免疫治疗,但具体方案需结合分期和患者情况综合判断。`
const md = getMarkdown('custom-html-pancreatic-unknown-open')
const streamingNodes = parseMarkdownToStructure(markdown, md, { final: false }) as any[]
const finalNodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
expect(streamingNodes).toHaveLength(1)
expect(streamingNodes[0]?.type).toBe('html_block')
expect(streamingNodes[0]?.loading).toBe(true)
expect(finalNodes).toHaveLength(1)
expect(finalNodes[0]?.type).toBe('html_block')
expect(finalNodes[0]?.loading).toBe(false)
expect(String(finalNodes[0]?.content ?? '')).toContain('胰腺癌是一种恶性程度很高的消化系统肿瘤')
})
it('keeps custom html blocks intact when attribute values contain ">"', () => {
const markdown = `<think data-x="a>b">
outer start
<think data-y="c>d">inner done</think>
outer end</think>`
const tags = ['think']
const md = getMarkdown('custom-html-quoted-gt-regression', { customHtmlTags: tags })
const nodes = parseMarkdownToStructure(markdown, md, { customHtmlTags: tags, final: true }) as any[]
expect(nodes).toHaveLength(1)
expect(nodes[0]?.type).toBe('think')
expect(nodes[0]?.loading).toBe(false)
expect(nodes[0]?.attrs).toEqual([['data-x', 'a>b']])
expect(String(nodes[0]?.content ?? '')).toContain('<think data-y="c>d">inner done</think>')
expect(String(nodes[0]?.content ?? '')).toContain('outer end')
})
it('keeps the outer custom tag loading in streaming mode when only the nested same tag is closed', () => {
const markdown = `<think>outer start
<think>inner done</think>
outer end`
const tags = ['think']
const md = getMarkdown('custom-html-nested-same-tag-streaming', { customHtmlTags: tags })
const nodes = parseMarkdownToStructure(markdown, md, { customHtmlTags: tags, final: false }) as any[]
expect(nodes).toHaveLength(1)
expect(nodes[0]?.type).toBe('think')
expect(nodes[0]?.loading).toBe(true)
expect(String(nodes[0]?.content ?? '')).toBe('outer start\n\n<think>inner done</think>\n\nouter end')
})
})
@@ -0,0 +1,72 @@
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
import { describe, expect, it } from 'vitest'
describe('custom html tag table regression', () => {
const tags = ['my_component']
const md = getMarkdown('custom-html-tag-in-table-regression', { customHtmlTags: tags })
it('keeps all table rows when repeated custom tags are used in cells', () => {
const markdown = `| Item| Value |
|--------|-------|
| A | <my_component label="Name"></my_component> |
| B | <my_component label="Name 2"></my_component> |
| C | domain.com |`
const nodes = parseMarkdownToStructure(markdown, md, { customHtmlTags: tags, final: true }) as any[]
expect(nodes).toHaveLength(1)
expect(nodes[0]?.type).toBe('table')
const table = nodes[0]
expect(table.rows).toHaveLength(3)
expect(table.rows[0]?.cells?.[1]?.children?.[0]?.type).toBe('my_component')
expect(table.rows[1]?.cells?.[1]?.children?.[0]?.type).toBe('my_component')
expect(table.rows[2]?.cells?.[1]?.children?.[0]?.type).toBe('link')
const firstCustomCellChildren = table.rows[0]?.cells?.[1]?.children ?? []
expect(
firstCustomCellChildren.some((c: any) => c?.type === 'html_inline' && String(c?.content ?? '').includes('</my_component>')),
).toBe(false)
})
const sameLineSuffixCases = [
{ label: 'pipe', suffix: '| tail' },
{ label: 'heading marker', suffix: '## heading-like' },
{ label: 'bullet marker', suffix: '- list-like' },
{ label: 'ordered marker', suffix: '1. ordered-like' },
{ label: 'blockquote marker', suffix: '> quote-like' },
{ label: 'admonition marker', suffix: '::: tip' },
{ label: 'math marker', suffix: '$$math-like' },
] as const
it.each(sameLineSuffixCases)(
'does not break table when custom tag close is followed by $label inside a cell',
({ suffix }) => {
const markdown = `| Item | Value |
| --- | --- |
| A | <my_component label="Name"></my_component>${suffix} |
| B | ok |`
const nodes = parseMarkdownToStructure(markdown, md, { customHtmlTags: tags, final: true }) as any[]
expect(nodes).toHaveLength(1)
expect(nodes[0]?.type).toBe('table')
expect(nodes[0]?.rows).toHaveLength(2)
expect(nodes[0]?.rows?.[1]?.cells?.[0]?.children?.[0]?.content).toBe('B')
},
)
it('does not break blockquote table rows after custom tag close in a cell', () => {
const markdown = `> | Item | Value |
> | --- | --- |
> | A | <my_component label="Name"></my_component>## heading-like |
> | B | ok |`
const nodes = parseMarkdownToStructure(markdown, md, { customHtmlTags: tags, final: true }) as any[]
expect(nodes).toHaveLength(1)
expect(nodes[0]?.type).toBe('blockquote')
const table = nodes[0]?.children?.[0]
expect(table?.type).toBe('table')
expect(table?.rows).toHaveLength(2)
expect(table?.rows?.[1]?.cells?.[0]?.children?.[0]?.content).toBe('B')
})
})
@@ -0,0 +1,67 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import D2BlockNode from '../src/components/D2BlockNode/D2BlockNode.vue'
vi.mock('../src/components/D2BlockNode/d2', () => ({
getD2: vi.fn(async () => class FakeD2 {
async compile(code: string) {
return {
diagram: { code },
renderOptions: {},
}
}
async render() {
return {
svg: '<svg width="364" height="766" viewBox="0 0 364 766"><svg class="inner-diagram" width="364" height="766" viewBox="0 0 364 766"><rect width="100" height="100" /></svg></svg>',
}
}
}),
}))
async function flushRender() {
await nextTick()
await Promise.resolve()
await Promise.resolve()
await new Promise<void>(resolve => setTimeout(resolve, 0))
}
async function waitForPreview(wrapper: ReturnType<typeof mount>, timeout = 1000) {
const start = Date.now()
while (!wrapper.find('.d2-svg').exists()) {
if (Date.now() - start > timeout)
throw new Error('Timed out waiting for D2 preview to render')
await flushRender()
}
}
describe('d2 block layout', () => {
it('scales only the root svg and does not pin preview height with stale min-heights', async () => {
const wrapper = mount(D2BlockNode as any, {
props: {
node: {
type: 'code_block',
language: 'd2',
code: 'a -> b',
raw: '```d2\na -> b\n```',
},
loading: false,
},
attachTo: document.body,
})
await waitForPreview(wrapper)
const svgMarkup = wrapper.get('.d2-svg').html()
expect(svgMarkup.match(/markstream-d2-root-svg/g)?.length ?? 0).toBe(1)
const setupState = (wrapper.vm as any).$?.setupState
setupState.bodyMinHeight = 5079
await nextTick()
expect(wrapper.get('.d2-block-body').attributes('style') || '').not.toContain('5079')
wrapper.unmount()
})
})
@@ -0,0 +1,18 @@
import { isMathLike } from 'stream-markdown-parser'
import { expect, it } from 'vitest'
it('recognizes \"\\boldsymbol{...}\" as math-like', () => {
expect(isMathLike('\\boldsymbol{\\beta}')).toBe(true)
expect(isMathLike('\\(C++\\)')).toBe(false)
expect(isMathLike('(W)')).toBe(true)
expect(isMathLike('\\(f^{(k)}(a)\\)')).toBe(true)
expect(isMathLike('\\(W^\perp\\)')).toBe(true)
expect(isMathLike('\\(2025/9/30 21:37:24\\)')).toBe(false)
expect(isMathLike('operatorname{')).toBe(true)
expect(isMathLike('served from /vue-markdown-icon.svg')).toBe(false)
expect(isMathLike('served from vue-markdown-icon.1')).toBe(false)
expect(isMathLike('get_time')).toBe(false)
expect(isMathLike('or **matrix of coefficients**')).toBe(false)
expect(isMathLike('Quasi-Streaming')).toBe(false)
expect(isMathLike('\frac')).toBe(true)
})
@@ -0,0 +1,153 @@
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
import { describe, expect, it } from 'vitest'
function collectCodeBlocks(nodes: any[]): any[] {
const result: any[] = []
const visit = (node: any) => {
if (!node || typeof node !== 'object')
return
if (node.type === 'code_block')
result.push(node)
if (Array.isArray(node.children))
node.children.forEach(visit)
if (Array.isArray(node.items))
node.items.forEach(visit)
// Some node types (table, list_item, etc.) contain nested arrays/objects.
// Cover the common shapes used in this repo.
if (node.header)
visit(node.header)
if (Array.isArray(node.rows))
node.rows.forEach(visit)
if (Array.isArray(node.cells))
node.cells.forEach(visit)
if (Array.isArray(node.term))
node.term.forEach(visit)
if (Array.isArray(node.definition))
node.definition.forEach(visit)
}
nodes.forEach(visit)
return result
}
describe('parseMarkdownToStructure - diff code block metadata', () => {
it('preserves diff=true and language/file for `diff json:package.json` fence', () => {
const md = getMarkdown('t')
const markdown = [
'2. Create the main Electron file:',
'',
'```javascript:electron/main.js',
'const { app, BrowserWindow } = require(\'electron\');',
'const path = require(\'path\');',
'const isDev = process.env.NODE_ENV === \'development\';',
'',
'let mainWindow;',
'',
'function createWindow() {',
' mainWindow = new BrowserWindow({',
' width: 900,',
' height: 680,',
' webPreferences: {',
' nodeIntegration: true,',
' contextIsolation: false',
' }',
' });',
'',
' const url = isDev',
' ? \'http://localhost:5173\'',
' : `file://' + '${' + 'path.join(__dirname, \'../dist/index.html\')' + '}`;',
'',
' mainWindow.loadURL(url);',
'',
' if (isDev) {',
' mainWindow.webContents.openDevTools();',
' }',
'',
' mainWindow.on(\'closed\', () => {',
' mainWindow = null;',
' });',
'}',
'',
'app.on(\'ready\', createWindow);',
'',
'app.on(\'window-all-closed\', () => {',
' if (process.platform !== \'darwin\') {',
' app.quit();',
' }',
'});',
'',
'app.on(\'activate\', () => {',
' if (mainWindow === null) {',
' createWindow();',
' }',
'});',
'```',
'',
'3. Update package.json:',
'',
'```diff json:package.json',
'{',
' "name": "markstream-vue",',
' "type": "module",',
'- "version": "0.0.49",',
'+ "version": "0.0.54-beta.1",',
' "packageManager": "pnpm@10.16.1",',
' "description": "A Vue 3 component that renders Markdown string content as HTML, supporting custom components and advanced markdown features.",',
' "author": "Simon He",',
' "license": "MIT",',
' "repository": {',
' "type": "git",',
' "url": "git + git@github.com:Simon-He95/markstream-vue.git"',
' },',
' "bugs": {',
' "url": "https://github.com/Simon-He95/markstream-vue/issues"',
' },',
' "keywords": [',
' "vue",',
' "vue3",',
' "markdown",',
' "markdown-to-html",',
' "markdown-renderer",',
' "vue-markdown",',
' "vue-component",',
' "html",',
' "renderer",',
' "custom-component"',
' ],',
' "exports": {',
' ".": {',
' "types": "./dist/types/exports.d.ts",',
' "import": "./dist/index.js",',
' "require": "./dist/index.cjs"',
' },',
' "./index.css": "./dist/index.css",',
' "./index.tailwind.css": "./dist/index.tailwind.css",',
' "./tailwind": "./dist/tailwind.ts"',
' },',
' "main": "./dist/index.js",',
' "module": "./dist/index.js",',
' "types": "./dist/types/exports.d.ts",',
' "files": [',
' "dist"',
' ],',
'}',
'```',
].join('\n')
const nodes = parseMarkdownToStructure(markdown, md)
const blocks = collectCodeBlocks(nodes)
expect(blocks.length).toBeGreaterThanOrEqual(2)
const first = blocks[0]
expect(first.language).toMatch(/^javascript(:|$)/)
expect(String(first.language)).toContain(':electron/main.js')
expect(!!first.diff).toBe(false)
const second = blocks[1]
expect(second.diff).toBe(true)
// language should be parsed as the actual language (json) and keep the file hint.
expect(String(second.language)).toMatch(/^json(:|$)/)
expect(String(second.language)).toContain(':package.json')
})
})
@@ -0,0 +1,111 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
import { describe, expect, it } from 'vitest'
const md = getMarkdown('e2e')
const FIXTURES_DIR = resolve(__dirname, 'fixtures')
describe('e2e markdown parsing (fixtures)', () => {
const files = [
'headings.md',
'code-diff.md',
'table.md',
'admonition.md',
'math.md',
'footnotes.md',
'image-link.md',
'checkbox.md',
'mermaid.md',
// edge-case fixtures
'unclosed-fence.md',
'trailing-backticks.md',
'unmatched-brackets.md',
'escaped-brackets.md',
'nested-lists-edge.md',
'fence-with-meta.md',
]
for (const f of files) {
it(`parses fixture ${f}`, () => {
const src = readFileSync(resolve(FIXTURES_DIR, f), 'utf8')
const nodes = parseMarkdownToStructure(src, md)
expect(nodes).toBeInstanceOf(Array)
// Ensure we have at least one top-level node
expect(nodes.length).toBeGreaterThan(0)
// Create a stable, minimal serialization for snapshot
// Helper to find first descendant text for a node
const findFirstText = (node: any): string | undefined => {
if (!node)
return undefined
if (node.type === 'text' && typeof node.content === 'string')
return node.content.slice(0, 80)
if (Array.isArray(node.children)) {
for (const c of node.children) {
const t = findFirstText(c)
if (t)
return t
}
}
return undefined
}
const pickMeta = (meta: any) => {
if (!meta || typeof meta !== 'object')
return undefined
const keys = ['diff', 'language', 'closed', 'unclosed']
const out: any = {}
for (const k of keys) {
if (k in meta)
out[k] = meta[k]
}
return Object.keys(out).length ? out : undefined
}
const minimal = nodes.map((n: any) => {
const out: any = { type: n.type }
out.firstText = findFirstText(n)
if (n.meta)
out.meta = pickMeta(n.meta)
if (n.type === 'heading') {
out.level = (n as any).level
out.children = (n as any).children?.map((c: any) => c.type)
}
else if (n.type === 'list') {
out.ordered = (n as any).ordered
out.items = (n as any).items?.length ?? 0
}
else if (n.type === 'code_block') {
out.language = (n as any).language
out.diff = !!(n as any).diff
// include a short preview of code
out.preview = (n as any).code ? (n as any).code.slice(0, 120) : undefined
}
else if (n.type === 'table') {
out.rows = (n as any).rows?.length ?? 0
}
else if (n.type === 'footnote') {
out.items = (n as any).items?.length ?? 0
}
else {
// generic: record if has children
out.children = (n as any).children ? (n as any).children.map((item: any) => item.type) : undefined
}
// Tolerate environment differences for escaped backslash in a specific fixture
if (f === 'escaped-brackets.md' && typeof out.firstText === 'string') {
// Normalize to snapshot’s expected form (no visible backslash)
out.firstText = out.firstText
.replace('literal backslash \\ and', 'literal backslash and')
.replace('literal backslash and', 'literal backslash and')
}
return out
})
// In some environments, a literal backslash may be normalized in text nodes.
// Keep snapshot tolerant to a missing visual backslash by stringifying the object.
expect(minimal).toMatchSnapshot(f)
})
}
})
@@ -0,0 +1,814 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`markdownRender node e2e coverage > renders admonition node 1`] = `
"<div data-v-8dfe8a80="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="admonition">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<div data-v-c8910e11="" data-v-8dfe8a80="" class="admonition admonition-stable">
<!-- Legend-style title that sits on the border -->
<div data-v-c8910e11="" id="admonition-stable" class="admonition-stable">
<!-- SVG icons per type --><svg data-v-c8910e11="" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="admonition-stable">
<path data-v-c8910e11="" d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path>
<path data-v-c8910e11="" d="M12 9v4"></path>
<path data-v-c8910e11="" d="M12 17h.01"></path>
</svg><span data-v-c8910e11="" class="admonition-stable">Warning</span>
<!--v-if-->
</div>
<div data-v-c8910e11="" id="admonition-stable-content" class="admonition-stable" aria-labelledby="admonition-stable">
<div data-v-8dfe8a80="" data-v-c8910e11="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="paragraph">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<p data-v-93ee5c2d="" data-v-8dfe8a80="" dir="auto" class="paragraph-node" typewriter="true" is-dark="false"><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="admonition-stable-renderer-0-0-0"><span data-v-a84b8095="">Admonition content</span>
<!--v-if--></span>
</p>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>
</div>
</div>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>"
`;
exports[`markdownRender node e2e coverage > renders blockquote node 1`] = `
"<div data-v-8dfe8a80="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="blockquote">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<blockquote data-v-8ae59609="" data-v-8dfe8a80="" class="blockquote" dir="auto" is-dark="false">
<div data-v-8dfe8a80="" data-v-8ae59609="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="paragraph">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<p data-v-93ee5c2d="" data-v-8dfe8a80="" dir="auto" class="paragraph-node" typewriter="true" is-dark="false"><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="blockquote-markdown-renderer-0-0-0"><span data-v-a84b8095="">Blockquote rendered content.</span>
<!--v-if--></span>
</p>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>
</blockquote>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>"
`;
exports[`markdownRender node e2e coverage > renders checkbox nodes 1`] = `
"<div data-v-8dfe8a80="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="list">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<ul data-v-b195669a="" data-v-8dfe8a80="" class="list-node list-disc" is-dark="false">
<li data-v-d94d2511="" data-v-b195669a="" class="list-item" dir="auto">
<div data-v-8dfe8a80="" data-v-d94d2511="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="paragraph">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<p data-v-93ee5c2d="" data-v-8dfe8a80="" dir="auto" class="paragraph-node" typewriter="true" is-dark="false"><span data-v-31e4c290="" data-v-93ee5c2d="" class="checkbox-node" role="img" aria-label="checked" index-key="list-item-markdown-renderer-0-0-0-0"><!-- Unchecked --><!-- Checked --><svg data-v-31e4c290="" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" class="checkbox-icon checkbox-checked"><rect data-v-31e4c290="" x="3" y="3" width="18" height="18" rx="4" fill="currentColor"></rect><path data-v-31e4c290="" d="M9 12l2 2 4-4" stroke="hsl(var(--ms-background))" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"></path></svg></span>
<!----><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="list-item-markdown-renderer-0-0-0-2"><span data-v-a84b8095=""> Completed task</span>
<!--v-if--></span>
<!---->
</p>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>
</li>
<li data-v-d94d2511="" data-v-b195669a="" class="list-item" dir="auto">
<div data-v-8dfe8a80="" data-v-d94d2511="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="paragraph">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<p data-v-93ee5c2d="" data-v-8dfe8a80="" dir="auto" class="paragraph-node" typewriter="true" is-dark="false"><span data-v-31e4c290="" data-v-93ee5c2d="" class="checkbox-node" role="img" aria-label="unchecked" index-key="list-item-markdown-renderer-0-1-0-0"><!-- Unchecked --><svg data-v-31e4c290="" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" class="checkbox-icon checkbox-unchecked"><rect data-v-31e4c290="" x="3" y="3" width="18" height="18" rx="4" stroke="currentColor" stroke-width="2"></rect></svg></span>
<!----><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="list-item-markdown-renderer-0-1-0-2"><span data-v-a84b8095=""> Pending task</span>
<!--v-if--></span>
<!---->
</p>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>
</li>
</ul>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>"
`;
exports[`markdownRender node e2e coverage > renders code block node 1`] = `
"<div data-v-8dfe8a80="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="code_block">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals --><pre data-v-8dfe8a80="" class="language-ts" aria-busy="false" aria-label="Code block: ts" data-language="ts" data-markstream-pre="1" tabindex="0" loading="false" index-key="markdown-renderer-0" stream="true" showtooltips="true" is-dark="false"><code translate="no">export const sum = (a: number, b: number) =&gt; a + b
</code></pre>
</div>
</div>
<!--v-if-->
</div>"
`;
exports[`markdownRender node e2e coverage > renders complex inline code with emphasis and multiple code spans 1`] = `
"<div data-v-8dfe8a80="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="list">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<ol data-v-b195669a="" data-v-8dfe8a80="" class="list-node list-decimal" is-dark="false">
<li data-v-d94d2511="" data-v-b195669a="" class="list-item" dir="auto" value="1">
<div data-v-8dfe8a80="" data-v-d94d2511="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="paragraph">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<p data-v-93ee5c2d="" data-v-8dfe8a80="" dir="auto" class="paragraph-node" typewriter="true" is-dark="false"><strong data-v-b1e65240="" data-v-93ee5c2d="" class="strong-node"><span data-v-a84b8095="" data-v-b1e65240="" class="whitespace-pre-wrap break-words text-node" index-key="list-item-markdown-renderer-0-0-0-0-0"><span data-v-a84b8095="">整合冗余条款:</span><!--v-if--></span></strong><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="list-item-markdown-renderer-0-0-0-1"><span data-v-a84b8095=""> 将原</span>
<!--v-if--></span><code data-v-36040df8="" data-v-93ee5c2d="" class="inline-code" index-key="list-item-markdown-renderer-0-0-0-2"><span data-v-36040df8="">1-(5)</span><!--v-if--></code><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="list-item-markdown-renderer-0-0-0-3"><span data-v-a84b8095="">、</span>
<!--v-if--></span><code data-v-36040df8="" data-v-93ee5c2d="" class="inline-code" index-key="list-item-markdown-renderer-0-0-0-4"><span data-v-36040df8="">3-(3)</span><!--v-if--></code><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="list-item-markdown-renderer-0-0-0-5"><span data-v-a84b8095="">、</span>
<!--v-if--></span><code data-v-36040df8="" data-v-93ee5c2d="" class="inline-code" index-key="list-item-markdown-renderer-0-0-0-6"><span data-v-36040df8="">3-(4)</span><!--v-if--></code><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="list-item-markdown-renderer-0-0-0-7"><span data-v-a84b8095="">中关于“缴纳费用”和“告知法律状态变化”的核心义务合并,统一放入</span>
<!--v-if--></span><code data-v-36040df8="" data-v-93ee5c2d="" class="inline-code" index-key="list-item-markdown-renderer-0-0-0-8"><span data-v-36040df8="">1-(4)</span><!--v-if--></code><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="list-item-markdown-renderer-0-0-0-9"><span data-v-a84b8095="">“法律状态维护与通知义务”中,使其逻辑更集中,避免了在不同部分重复提及相似义务。</span>
<!--v-if--></span>
</p>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>
</li>
</ol>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>"
`;
exports[`markdownRender node e2e coverage > renders definition list node 1`] = `
"<div data-v-8dfe8a80="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="definition_list">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<dl data-v-084c0831="" data-v-8dfe8a80="" class="definition-list" is-dark="false">
<dt data-v-084c0831="" class="definition-term">
<div data-v-8dfe8a80="" data-v-084c0831="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="text">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true"><span data-v-a84b8095="" data-v-8dfe8a80="" class="whitespace-pre-wrap break-words text-node" index-key="definition-term-markdown-renderer-0-0-0" typewriter="true" is-dark="false"><span data-v-a84b8095="">Term</span>
<!--v-if--></span>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>
</dt>
<dd data-v-084c0831="" class="definition-desc">
<div data-v-8dfe8a80="" data-v-084c0831="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="paragraph">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<p data-v-93ee5c2d="" data-v-8dfe8a80="" dir="auto" class="paragraph-node" typewriter="true" is-dark="false"><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="definition-desc-markdown-renderer-0-0-0-0"><span data-v-a84b8095="">Definition details</span>
<!--v-if--></span>
</p>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>
</dd>
</dl>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>"
`;
exports[`markdownRender node e2e coverage > renders emoji node 1`] = `
"<div data-v-8dfe8a80="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="paragraph">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<p data-v-93ee5c2d="" data-v-8dfe8a80="" dir="auto" class="paragraph-node" typewriter="true" is-dark="false"><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-0"><span data-v-a84b8095="">Smile with </span>
<!--v-if--></span><span data-v-b001e831="" data-v-93ee5c2d="" class="emoji-node" index-key="markdown-renderer-0-1">😄</span><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-2"><span data-v-a84b8095=""> emoji.</span>
<!--v-if--></span>
</p>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>"
`;
exports[`markdownRender node e2e coverage > renders emphasis node 1`] = `
"<div data-v-8dfe8a80="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="paragraph">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<p data-v-93ee5c2d="" data-v-8dfe8a80="" dir="auto" class="paragraph-node" typewriter="true" is-dark="false"><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-0"><span data-v-a84b8095="">This is </span>
<!--v-if--></span><em data-v-ca49685e="" data-v-93ee5c2d="" class="emphasis-node"><span data-v-a84b8095="" data-v-ca49685e="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-1-0"><span data-v-a84b8095="">italic text</span><!--v-if--></span></em><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-2"><span data-v-a84b8095="">.</span>
<!--v-if--></span>
</p>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>"
`;
exports[`markdownRender node e2e coverage > renders footnote nodes 1`] = `
"<div data-v-8dfe8a80="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="paragraph">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<p data-v-93ee5c2d="" data-v-8dfe8a80="" dir="auto" class="paragraph-node" typewriter="true" is-dark="false"><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-0"><span data-v-a84b8095="">A footnote reference</span>
<!--v-if--></span><sup data-v-ba0412bc="" data-v-93ee5c2d="" id="fnref-1" class="footnote-reference" index-key="markdown-renderer-0-1"><span data-v-ba0412bc="" href="#footnote-1" title="查看脚注 1" class="footnote-link cursor-pointer">[1]</span></sup><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-2"><span data-v-a84b8095="">.</span>
<!--v-if--></span>
</p>
</transition-stub>
</div>
</div>
<div data-v-8dfe8a80="" class="node-slot" data-node-index="1" data-node-type="footnote">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<div data-v-8dfe8a80="" id="fnref--1" class="footnote-node flex text-sm leading-relaxed border-t border-[var(--footnote-border)] pt-2" is-dark="false">
<!-- <span class="font-semibold mr-2 text-[#0366d6]">[{{ node.id }}]</span> -->
<div class="flex-1">
<div data-v-8dfe8a80="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="paragraph">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<p data-v-93ee5c2d="" data-v-8dfe8a80="" dir="auto" class="paragraph-node" typewriter="true" is-dark="false"><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="footnote-markdown-renderer-1-0-0"><span data-v-a84b8095="">Footnote explanation</span>
<!--v-if--></span><a data-v-31914872="" data-v-93ee5c2d="" class="footnote-anchor text-sm hover:underline cursor-pointer" href="#fnref-1" title="返回引用 1" index-key="footnote-markdown-renderer-1-0-1"> ↩︎ </a>
</p>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>
</div>
</div>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>"
`;
exports[`markdownRender node e2e coverage > renders footnote reference node 1`] = `
"<div data-v-8dfe8a80="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="footnote_reference">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true"><sup data-v-ba0412bc="" data-v-8dfe8a80="" id="fnref-1" class="footnote-reference" index-key="markdown-renderer-0" typewriter="true" is-dark="false"><span data-v-ba0412bc="" href="#footnote-1" title="查看脚注 1" class="footnote-link cursor-pointer">[1]</span></sup></transition-stub>
</div>
</div>
<!--v-if-->
</div>"
`;
exports[`markdownRender node e2e coverage > renders hardbreak node 1`] = `
"<div data-v-8dfe8a80="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="paragraph">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<p data-v-93ee5c2d="" data-v-8dfe8a80="" dir="auto" class="paragraph-node" typewriter="true" is-dark="false"><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-0"><span data-v-a84b8095="">Line one</span>
<!--v-if--></span><br data-v-2587360c="" data-v-93ee5c2d="" class="hard-break" index-key="markdown-renderer-0-1"><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-2"><span data-v-a84b8095="">Line two</span>
<!--v-if--></span>
</p>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>"
`;
exports[`markdownRender node e2e coverage > renders heading node 1`] = `
"<div data-v-8dfe8a80="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="heading">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<h1 data-v-9f883140="" data-v-8dfe8a80="" class="heading-node heading-1" dir="auto" typewriter="true" is-dark="false"><span data-v-a84b8095="" data-v-9f883140="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-0"><span data-v-a84b8095="">Heading Node Title</span>
<!--v-if--></span>
</h1>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>"
`;
exports[`markdownRender node e2e coverage > renders highlight node 1`] = `
"<div data-v-8dfe8a80="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="paragraph">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<p data-v-93ee5c2d="" data-v-8dfe8a80="" dir="auto" class="paragraph-node" typewriter="true" is-dark="false"><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-0"><span data-v-a84b8095="">Use </span>
<!--v-if--></span><mark data-v-9bc40b1b="" data-v-93ee5c2d="" class="highlight-node"><span data-v-a84b8095="" data-v-9bc40b1b="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-1-0"><span data-v-a84b8095="">highlighted text</span>
<!--v-if--></span>
</mark><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-2"><span data-v-a84b8095=""> for emphasis.</span>
<!--v-if--></span>
</p>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>"
`;
exports[`markdownRender node e2e coverage > renders image links with relative target stay inline 1`] = `
"<div data-v-8dfe8a80="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="paragraph">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<p data-v-93ee5c2d="" data-v-8dfe8a80="" dir="auto" class="paragraph-node" typewriter="true" is-dark="false"><a data-v-994bfd08="" data-v-93ee5c2d="" class="link-node" href="https://www.npmjs.com/package/markstream-vue" title="" aria-label="Link: https://www.npmjs.com/package/markstream-vue" aria-hidden="false" target="_blank" rel="noopener noreferrer" style="--underline-height: 2px; --underline-bottom: -3px; --underline-opacity: 0.35; --underline-rest-opacity: 0.175; --underline-duration: 1.6s; --underline-timing: ease-in-out; --underline-iteration: infinite;"><span data-v-4ff2ac62="" data-v-994bfd08="" class="image-node-container" index-key="markdown-renderer-0-0-0"><img data-v-4ff2ac62="" src="https://img.shields.io/npm/v/markstream-vue?color=a1b858&amp;label=" alt="NPM version" title="NPM version" class="image-node__img is-loaded" fetchpriority="high" decoding="sync" tabindex="-1" aria-label="NPM version"></span></a> <a data-v-994bfd08="" data-v-93ee5c2d="" class="link-node" href="README.zh-CN.md" title="" aria-label="Link: README.zh-CN.md" aria-hidden="false" target="_blank" rel="noopener noreferrer" style="--underline-height: 2px; --underline-bottom: -3px; --underline-opacity: 0.35; --underline-rest-opacity: 0.175; --underline-duration: 1.6s; --underline-timing: ease-in-out; --underline-iteration: infinite;"><span data-v-4ff2ac62="" data-v-994bfd08="" class="image-node-container" index-key="markdown-renderer-0-2-0"><img data-v-4ff2ac62="" src="https://img.shields.io/badge/docs-%E4%B8%AD%E6%96%87%E6%96%87%E6%A1%A3-blue" alt="中文版" title="中文版" class="image-node__img is-loaded" fetchpriority="high" decoding="sync" tabindex="-1" aria-label="中文版"></span></a></p>
</div>
</div>
<!--v-if-->
</div>"
`;
exports[`markdownRender node e2e coverage > renders image node 1`] = `
"<div data-v-8dfe8a80="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="paragraph">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<p data-v-93ee5c2d="" data-v-8dfe8a80="" dir="auto" class="paragraph-node" typewriter="true" is-dark="false"><span data-v-4ff2ac62="" data-v-93ee5c2d="" class="image-node-container" index-key="markdown-renderer-0-0"><img data-v-4ff2ac62="" src="https://example.com/vue.png" alt="Vue Logo" title="Vue Logo" class="image-node__img is-loaded" fetchpriority="high" decoding="sync" tabindex="-1" aria-label="Vue Logo"></span></p>
</div>
</div>
<!--v-if-->
</div>"
`;
exports[`markdownRender node e2e coverage > renders inline code node 1`] = `
"<div data-v-8dfe8a80="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="paragraph">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<p data-v-93ee5c2d="" data-v-8dfe8a80="" dir="auto" class="paragraph-node" typewriter="true" is-dark="false"><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-0"><span data-v-a84b8095="">Here is </span>
<!--v-if--></span><code data-v-36040df8="" data-v-93ee5c2d="" class="inline-code" index-key="markdown-renderer-0-1"><span data-v-36040df8="">const answer = 42</span><!--v-if--></code><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-2"><span data-v-a84b8095=""> inline.</span>
<!--v-if--></span>
</p>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>"
`;
exports[`markdownRender node e2e coverage > renders insert node 1`] = `
"<div data-v-8dfe8a80="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="paragraph">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<p data-v-93ee5c2d="" data-v-8dfe8a80="" dir="auto" class="paragraph-node" typewriter="true" is-dark="false"><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-0"><span data-v-a84b8095="">Here comes </span>
<!--v-if--></span><ins data-v-1e673445="" data-v-93ee5c2d="" class="insert-node"><span data-v-a84b8095="" data-v-1e673445="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-1-0"><span data-v-a84b8095="">inserted text</span>
<!--v-if--></span>
</ins><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-2"><span data-v-a84b8095=""> in the paragraph.</span>
<!--v-if--></span>
</p>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>"
`;
exports[`markdownRender node e2e coverage > renders link node 1`] = `
"<div data-v-8dfe8a80="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="paragraph">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<p data-v-93ee5c2d="" data-v-8dfe8a80="" dir="auto" class="paragraph-node" typewriter="true" is-dark="false"><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-0"><span data-v-a84b8095="">Visit </span>
<!--v-if--></span><a data-v-994bfd08="" data-v-93ee5c2d="" class="link-node" href="https://vuejs.org" title="" aria-label="Link: https://vuejs.org" aria-hidden="false" target="_blank" rel="noopener noreferrer" style="--underline-height: 2px; --underline-bottom: -3px; --underline-opacity: 0.35; --underline-rest-opacity: 0.175; --underline-duration: 1.6s; --underline-timing: ease-in-out; --underline-iteration: infinite;"><span data-v-a84b8095="" data-v-994bfd08="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-1-0"><span data-v-a84b8095="">Vue</span>
<!--v-if--></span>
</a><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-2"><span data-v-a84b8095=""> now.</span>
<!--v-if--></span>
</p>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>"
`;
exports[`markdownRender node e2e coverage > renders math inline node 1`] = `
"<div data-v-8dfe8a80="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="paragraph">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<p data-v-93ee5c2d="" data-v-8dfe8a80="" dir="auto" class="paragraph-node" typewriter="true" is-dark="false"><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-0"><span data-v-a84b8095="">Einstein wrote </span>
<!--v-if--></span><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-1"><span data-v-a84b8095="">$E=mc^2$</span>
<!--v-if--></span><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-2"><span data-v-a84b8095="">.</span>
<!--v-if--></span>
</p>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>"
`;
exports[`markdownRender node e2e coverage > renders ordered list node 1`] = `
"<div data-v-8dfe8a80="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="list">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<ol data-v-b195669a="" data-v-8dfe8a80="" class="list-node list-decimal" is-dark="false">
<li data-v-d94d2511="" data-v-b195669a="" class="list-item" dir="auto" value="1">
<div data-v-8dfe8a80="" data-v-d94d2511="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="paragraph">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<p data-v-93ee5c2d="" data-v-8dfe8a80="" dir="auto" class="paragraph-node" typewriter="true" is-dark="false"><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="list-item-markdown-renderer-0-0-0-0"><span data-v-a84b8095="">First entry</span>
<!--v-if--></span>
</p>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>
</li>
<li data-v-d94d2511="" data-v-b195669a="" class="list-item" dir="auto" value="2">
<div data-v-8dfe8a80="" data-v-d94d2511="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="paragraph">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<p data-v-93ee5c2d="" data-v-8dfe8a80="" dir="auto" class="paragraph-node" typewriter="true" is-dark="false"><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="list-item-markdown-renderer-0-1-0-0"><span data-v-a84b8095="">Second entry</span>
<!--v-if--></span>
</p>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>
</li>
</ol>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>"
`;
exports[`markdownRender node e2e coverage > renders paragraph and text node 1`] = `
"<div data-v-8dfe8a80="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="paragraph">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<p data-v-93ee5c2d="" data-v-8dfe8a80="" dir="auto" class="paragraph-node" typewriter="true" is-dark="false"><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-0"><span data-v-a84b8095="">Simple paragraph content rendered as plain text.</span>
<!--v-if--></span>
</p>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>"
`;
exports[`markdownRender node e2e coverage > renders reference node 1`] = `
"<div data-v-8dfe8a80="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="paragraph">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<p data-v-93ee5c2d="" data-v-8dfe8a80="" dir="auto" class="paragraph-node" typewriter="true" is-dark="false"><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-0"><span data-v-a84b8095="">Cite research </span>
<!--v-if--></span><span data-v-7c86ecd2="" data-v-93ee5c2d="" class="reference-node cursor-pointer text-xs rounded-md px-1.5 mx-0.5" role="button" tabindex="0" index-key="markdown-renderer-0-1">1</span><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-2"><span data-v-a84b8095=""> for details.</span>
<!--v-if--></span>
</p>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>"
`;
exports[`markdownRender node e2e coverage > renders single badge image with trailing newline stays inline 1`] = `
"<div data-v-8dfe8a80="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="paragraph">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<p data-v-93ee5c2d="" data-v-8dfe8a80="" dir="auto" class="paragraph-node" typewriter="true" is-dark="false"><span data-v-4ff2ac62="" data-v-93ee5c2d="" class="image-node-container" index-key="markdown-renderer-0-0"><img data-v-4ff2ac62="" src="https://img.shields.io/npm/l/markstream-vue" alt="License" title="License" class="image-node__img is-loaded" fetchpriority="high" decoding="sync" tabindex="-1" aria-label="License"></span></p>
</div>
</div>
<!--v-if-->
</div>"
`;
exports[`markdownRender node e2e coverage > renders strikethrough node 1`] = `
"<div data-v-8dfe8a80="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="paragraph">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<p data-v-93ee5c2d="" data-v-8dfe8a80="" dir="auto" class="paragraph-node" typewriter="true" is-dark="false"><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-0"><span data-v-a84b8095="">This is </span>
<!--v-if--></span><del data-v-d7c2e36d="" data-v-93ee5c2d="" class="strikethrough-node"><span data-v-a84b8095="" data-v-d7c2e36d="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-1-0"><span data-v-a84b8095="">deleted text</span>
<!--v-if--></span>
</del><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-2"><span data-v-a84b8095="">.</span>
<!--v-if--></span>
</p>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>"
`;
exports[`markdownRender node e2e coverage > renders strong node 1`] = `
"<div data-v-8dfe8a80="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="paragraph">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<p data-v-93ee5c2d="" data-v-8dfe8a80="" dir="auto" class="paragraph-node" typewriter="true" is-dark="false"><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-0"><span data-v-a84b8095="">This is </span>
<!--v-if--></span><strong data-v-b1e65240="" data-v-93ee5c2d="" class="strong-node"><span data-v-a84b8095="" data-v-b1e65240="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-1-0"><span data-v-a84b8095="">bold text</span><!--v-if--></span></strong><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-2"><span data-v-a84b8095="">.</span>
<!--v-if--></span>
</p>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>"
`;
exports[`markdownRender node e2e coverage > renders subscript node 1`] = `
"<div data-v-8dfe8a80="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="paragraph">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<p data-v-93ee5c2d="" data-v-8dfe8a80="" dir="auto" class="paragraph-node" typewriter="true" is-dark="false"><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-0"><span data-v-a84b8095="">Chemical formula H</span>
<!--v-if--></span><sub data-v-40ad1ac6="" data-v-93ee5c2d="" class="subscript-node"><span data-v-a84b8095="" data-v-40ad1ac6="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-1-0"><span data-v-a84b8095="">2</span>
<!--v-if--></span>
</sub><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-2"><span data-v-a84b8095="">O is familiar.</span>
<!--v-if--></span>
</p>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>"
`;
exports[`markdownRender node e2e coverage > renders superscript node 1`] = `
"<div data-v-8dfe8a80="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="paragraph">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<p data-v-93ee5c2d="" data-v-8dfe8a80="" dir="auto" class="paragraph-node" typewriter="true" is-dark="false"><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-0"><span data-v-a84b8095="">Math uses x</span>
<!--v-if--></span><sup data-v-9c4a3c09="" data-v-93ee5c2d="" class="superscript-node"><span data-v-a84b8095="" data-v-9c4a3c09="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-1-0"><span data-v-a84b8095="">2</span>
<!--v-if--></span>
</sup><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-2"><span data-v-a84b8095=""> frequently.</span>
<!--v-if--></span>
</p>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>"
`;
exports[`markdownRender node e2e coverage > renders table node 1`] = `
"<div data-v-8dfe8a80="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="table">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<div data-v-8fb37ab3="" data-v-8dfe8a80="" class="table-node-wrapper" loading="false">
<table data-v-8fb37ab3="" class="table-node" aria-busy="false">
<!--v-if-->
<thead data-v-8fb37ab3="">
<tr data-v-8fb37ab3="">
<th data-v-8fb37ab3="" dir="auto" class="text-left">
<div data-v-8dfe8a80="" data-v-8fb37ab3="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="text">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true"><span data-v-a84b8095="" data-v-8dfe8a80="" class="whitespace-pre-wrap break-words text-node" index-key="table-th-markdown-renderer-0-0" typewriter="true" is-dark="false"><span data-v-a84b8095="">Name</span>
<!--v-if--></span>
</transition-stub>
</div>
</div>
<!--v-if-->
</div><button data-v-8fb37ab3="" type="button" class="table-node__resize-handle" aria-label="Resize columns 1 and 2"></button>
</th>
<th data-v-8fb37ab3="" dir="auto" class="text-left">
<div data-v-8dfe8a80="" data-v-8fb37ab3="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="text">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true"><span data-v-a84b8095="" data-v-8dfe8a80="" class="whitespace-pre-wrap break-words text-node" index-key="table-th-markdown-renderer-0-0" typewriter="true" is-dark="false"><span data-v-a84b8095="">Role</span>
<!--v-if--></span>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>
<!--v-if-->
</th>
</tr>
</thead>
<tbody data-v-8fb37ab3="">
<tr data-v-8fb37ab3="">
<td data-v-8fb37ab3="" class="text-left" dir="auto">
<div data-v-8dfe8a80="" data-v-8fb37ab3="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="text">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true"><span data-v-a84b8095="" data-v-8dfe8a80="" class="whitespace-pre-wrap break-words text-node" index-key="table-td-markdown-renderer-0-0" typewriter="true" is-dark="false"><span data-v-a84b8095="">Alice</span>
<!--v-if--></span>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>
</td>
<td data-v-8fb37ab3="" class="text-left" dir="auto">
<div data-v-8dfe8a80="" data-v-8fb37ab3="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="text">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true"><span data-v-a84b8095="" data-v-8dfe8a80="" class="whitespace-pre-wrap break-words text-node" index-key="table-td-markdown-renderer-0-0" typewriter="true" is-dark="false"><span data-v-a84b8095="">Developer</span>
<!--v-if--></span>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>
</td>
</tr>
</tbody>
</table>
<transition-stub data-v-8fb37ab3="" name="table-node-fade" appear="false" persisted="false" css="true">
<!--v-if-->
</transition-stub>
</div>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>"
`;
exports[`markdownRender node e2e coverage > renders thematic break node 1`] = `
"<div data-v-8dfe8a80="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="paragraph">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<p data-v-93ee5c2d="" data-v-8dfe8a80="" dir="auto" class="paragraph-node" typewriter="true" is-dark="false"><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-0-0"><span data-v-a84b8095="">First section.</span>
<!--v-if--></span>
</p>
</transition-stub>
</div>
</div>
<div data-v-8dfe8a80="" class="node-slot" data-node-index="1" data-node-type="thematic_break">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<hr data-v-18e4f209="" data-v-8dfe8a80="" class="hr-node" node="[object Object]" index-key="markdown-renderer-1" typewriter="true" is-dark="false">
</transition-stub>
</div>
</div>
<div data-v-8dfe8a80="" class="node-slot" data-node-index="2" data-node-type="paragraph">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<p data-v-93ee5c2d="" data-v-8dfe8a80="" dir="auto" class="paragraph-node" typewriter="true" is-dark="false"><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="markdown-renderer-2-0"><span data-v-a84b8095="">Second section.</span>
<!--v-if--></span>
</p>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>"
`;
exports[`markdownRender node e2e coverage > renders unordered list node 1`] = `
"<div data-v-8dfe8a80="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="list">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<ul data-v-b195669a="" data-v-8dfe8a80="" class="list-node list-disc" is-dark="false">
<li data-v-d94d2511="" data-v-b195669a="" class="list-item" dir="auto">
<div data-v-8dfe8a80="" data-v-d94d2511="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="paragraph">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<p data-v-93ee5c2d="" data-v-8dfe8a80="" dir="auto" class="paragraph-node" typewriter="true" is-dark="false"><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="list-item-markdown-renderer-0-0-0-0"><span data-v-a84b8095="">Item one</span>
<!--v-if--></span>
</p>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>
</li>
<li data-v-d94d2511="" data-v-b195669a="" class="list-item" dir="auto">
<div data-v-8dfe8a80="" data-v-d94d2511="" class="markstream-vue markdown-renderer">
<!--v-if-->
<div data-v-8dfe8a80="" class="node-slot" data-node-index="0" data-node-type="paragraph">
<div data-v-8dfe8a80="" class="node-content">
<!-- Skip wrapping code_block nodes in transitions to avoid touching Monaco editor internals -->
<transition-stub data-v-8dfe8a80="" name="typewriter" appear="true" persisted="false" css="true">
<p data-v-93ee5c2d="" data-v-8dfe8a80="" dir="auto" class="paragraph-node" typewriter="true" is-dark="false"><span data-v-a84b8095="" data-v-93ee5c2d="" class="whitespace-pre-wrap break-words text-node" index-key="list-item-markdown-renderer-0-1-0-0"><span data-v-a84b8095="">Item two</span>
<!--v-if--></span>
</p>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>
</li>
</ul>
</transition-stub>
</div>
</div>
<!--v-if-->
</div>"
`;
@@ -0,0 +1,56 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import Demo from '../../playground/src/pages/mermaid-export-demo.vue'
import MermaidBlockNode from '../../src/components/MermaidBlockNode/MermaidBlockNode.vue'
describe('playground mermaid-export demo', () => {
it('intercepts export and displays uploaded URL', async () => {
vi.useFakeTimers()
const wrapper = mount(Demo as any, { attachTo: document.body })
// Wait for mounted lifecycle
await nextTick()
// Find the rendered MermaidBlockNode instance
const m = wrapper.findComponent(MermaidBlockNode as any)
expect(m.exists()).toBeTruthy()
// Ensure the export button will be shown by setting mermaidAvailable
;(m.vm as any).mermaidAvailable = true
;(m.vm as any).showSource = false
await nextTick()
// Inject a fake SVG into the mermaid content area so export handler has something
const content = m.find('div._mermaid')
expect(content.exists()).toBeTruthy()
content.element.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg"><g></g></svg>'
await nextTick()
// Instead of relying on DOM click wiring, emit the `export` event directly on the child
const ev = {
payload: { type: 'export' },
defaultPrevented: false,
preventDefault() { this.defaultPrevented = true },
svgElement: content.element.querySelector('svg'),
svgString: '<svg></svg>',
}
;(m.vm as any).$emit('export', ev)
// Advance timers to resolve the component's fake upload (700ms)
vi.runAllTimers()
// allow microtasks scheduled by async handlers to run
await Promise.resolve()
await nextTick()
// The uploaded URL should be displayed by the demo
expect(wrapper.html()).toContain('Uploaded')
const link = wrapper.find('a')
expect(link.exists()).toBeTruthy()
expect(link.attributes('href') || '').toMatch(/^https:\/\/example\.com\/uploads/)
vi.useRealTimers()
})
})
@@ -0,0 +1,997 @@
/**
* @vitest-environment jsdom
*/
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { full as markdownItEmoji } from 'markdown-it-emoji'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import { defineComponent, h } from 'vue'
import { removeCustomComponents, setCustomComponents } from '../../src/utils/nodeComponents'
import { flushAll } from '../setup/flush-all'
interface Scenario {
name: string
markdown: string
expectedText?: string | string[]
props?: Record<string, any>
mountOptions?: Record<string, any>
assert?: (wrapper: VueWrapper<any>) => void | Promise<void>
skipSnapshot?: boolean
}
let MarkdownRender: any
// Use shared flushAll from test/setup/flush-all
async function mountMarkdown(markdown: string, props: Record<string, any> = {}, mountOptions: Record<string, any> = {}) {
const wrapper = mount(MarkdownRender, {
props: {
content: markdown,
...props,
customMarkdownIt: (md: any) => md.use(markdownItEmoji),
},
...mountOptions,
})
await flushAll()
return wrapper
}
function normalizeText(input: string) {
return input.replace(/\s+/g, ' ').trim()
}
function sanitizeSnapshotHtml(html: string, name: string) {
html = html.replace(/class="([^"]*)"/g, (_, classNames: string) => {
const normalized = classNames
.split(/\s+/)
.filter(Boolean)
.filter(className => !/^typewriter-(?:enter|leave)(?:-(?:from|to|active))?$/.test(className))
.join(' ')
return normalized ? `class="${normalized}"` : ''
})
if (name.includes('admonition'))
return html.replace(/admonition-[a-z0-9]+/gi, 'admonition-stable')
return html
}
describe('markdownRender node e2e coverage', () => {
beforeAll(async () => {
vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000)
MarkdownRender = (await import('../../src/components/NodeRenderer')).default
})
afterAll(() => {
; (Date.now as any).mockRestore?.()
})
const scenarios: Scenario[] = [
{
name: 'paragraph and text node',
markdown: 'Simple paragraph content rendered as plain text.',
expectedText: 'Simple paragraph content rendered as plain text.',
assert: (wrapper) => {
const paragraph = wrapper.find('p')
expect(paragraph.exists()).toBe(true)
},
},
{
name: 'heading node',
markdown: '# Heading Node Title',
expectedText: 'Heading Node Title',
assert: (wrapper) => {
const heading = wrapper.find('h1')
expect(heading.exists()).toBe(true)
},
},
{
name: 'blockquote node',
markdown: '> Blockquote rendered content.',
expectedText: 'Blockquote rendered content.',
assert: (wrapper) => {
const blockquote = wrapper.find('blockquote')
expect(blockquote.exists()).toBe(true)
},
},
{
name: 'unordered list node',
markdown: '- Item one\n- Item two',
expectedText: ['Item one', 'Item two'],
assert: (wrapper) => {
const list = wrapper.find('ul')
expect(list.exists()).toBe(true)
expect(list.findAll('li')).toHaveLength(2)
},
},
{
name: 'ordered list node',
markdown: '1. First entry\n2. Second entry',
expectedText: ['First entry', 'Second entry'],
assert: (wrapper) => {
const list = wrapper.find('ol')
expect(list.exists()).toBe(true)
expect(list.findAll('li')).toHaveLength(2)
},
},
{
name: 'checkbox nodes',
markdown: '- [x] Completed task\n- [ ] Pending task',
expectedText: ['Completed task', 'Pending task'],
assert: (wrapper) => {
const checkboxes = wrapper.findAll('.checkbox-node')
expect(checkboxes).toHaveLength(2)
expect(checkboxes[0].find('.checkbox-checked').exists()).toBe(true)
expect(checkboxes[1].find('.checkbox-unchecked').exists()).toBe(true)
},
},
{
name: 'link node',
markdown: 'Visit [Vue](https://vuejs.org) now.',
expectedText: ['Visit', 'Vue', 'now.'],
assert: (wrapper) => {
const link = wrapper.find('a[href="https://vuejs.org"]')
expect(link.exists()).toBe(true)
expect(link.text()).toBe('Vue')
},
},
{
name: 'image node',
markdown: '![Vue Logo](https://example.com/vue.png "Vue Logo")',
mountOptions: {
global: {
stubs: {
'transition': false,
'transition-group': false,
},
},
},
assert: (wrapper) => {
const paragraph = wrapper.find('p.paragraph-node')
expect(paragraph.exists()).toBe(true)
expect(paragraph.find('figure').exists()).toBe(false)
expect(paragraph.findAll('img')).toHaveLength(1)
const img = paragraph.find('img')
expect(img.exists()).toBe(true)
expect(img.attributes('src')).toBe('https://example.com/vue.png')
expect(img.attributes('title')).toBe('Vue Logo')
},
},
{
name: 'single badge image with trailing newline stays inline',
markdown: '![License](https://img.shields.io/npm/l/markstream-vue)\n',
mountOptions: {
global: {
stubs: {
'transition': false,
'transition-group': false,
},
},
},
assert: (wrapper) => {
const paragraph = wrapper.find('p.paragraph-node')
expect(paragraph.exists()).toBe(true)
expect(paragraph.find('figure').exists()).toBe(false)
expect(paragraph.findAll('img')).toHaveLength(1)
const img = paragraph.find('img')
expect(img.exists()).toBe(true)
expect(img.attributes('src')).toBe('https://img.shields.io/npm/l/markstream-vue')
},
},
{
name: 'image paragraph separated by blank lines stays inline',
markdown: `Before
![Vue Logo](https://example.com/vue.png "Vue Logo")
After`,
expectedText: ['Before', 'After'],
mountOptions: {
global: {
stubs: {
'transition': false,
'transition-group': false,
},
},
},
skipSnapshot: true,
assert: (wrapper) => {
const paragraphs = wrapper.findAll('p.paragraph-node')
expect(paragraphs).toHaveLength(3)
const imageParagraph = paragraphs[1]
expect(imageParagraph.find('figure').exists()).toBe(false)
expect(imageParagraph.findAll('img')).toHaveLength(1)
const img = imageParagraph.find('img')
expect(img.exists()).toBe(true)
expect(img.attributes('src')).toBe('https://example.com/vue.png')
expect(img.attributes('title')).toBe('Vue Logo')
},
},
{
name: 'inline image inside text stays inline',
markdown: 'Before ![Vue Logo](https://example.com/vue.png "Vue Logo") after.',
expectedText: ['Before', 'after.'],
mountOptions: {
global: {
stubs: {
'transition': false,
'transition-group': false,
},
},
},
skipSnapshot: true,
assert: (wrapper) => {
const paragraph = wrapper.find('p.paragraph-node')
expect(paragraph.exists()).toBe(true)
expect(paragraph.find('figure').exists()).toBe(false)
expect(paragraph.findAll('img')).toHaveLength(1)
const img = paragraph.find('img')
expect(img.exists()).toBe(true)
expect(img.attributes('src')).toBe('https://example.com/vue.png')
},
},
{
name: 'image links in same paragraph stay inline',
markdown: `[![NPM version](https://img.shields.io/npm/v/markstream-vue?color=a1b858&label=)](https://www.npmjs.com/package/markstream-vue)
[![Playground](https://img.shields.io/badge/playground-live-34c759)](https://markstream-vue.simonhe.me/)
[![CI](https://github.com/Simon-He95/markstream-vue/actions/workflows/ci.yml/badge.svg)](https://github.com/Simon-He95/markstream-vue/actions/workflows/ci.yml)`,
mountOptions: {
global: {
stubs: {
'transition': false,
'transition-group': false,
},
},
},
skipSnapshot: true,
assert: (wrapper) => {
const paragraph = wrapper.find('p.paragraph-node')
expect(paragraph.exists()).toBe(true)
expect(paragraph.element.tagName).toBe('P')
const childTagNames = Array.from(paragraph.element.children).map(child => child.tagName)
expect(childTagNames).toEqual(['A', 'A', 'A'])
const links = paragraph.findAll('a.link-node')
expect(links).toHaveLength(3)
const images = paragraph.findAll('img')
expect(images).toHaveLength(3)
expect(paragraph.findAll('figure')).toHaveLength(0)
// Each link should contain an img (possibly wrapped in a container span)
const linksWithImages = links.filter(link => link.find('img').exists())
expect(linksWithImages).toHaveLength(3)
expect(images[0].attributes('src')).toContain('img.shields.io/npm/v/markstream-vue')
expect(images[1].attributes('src')).toContain('img.shields.io/badge/playground-live-34c759')
expect(images[2].attributes('src')).toContain('/badge.svg')
},
},
{
name: 'image links with relative target stay inline',
markdown: `[![NPM version](https://img.shields.io/npm/v/markstream-vue?color=a1b858&label=)](https://www.npmjs.com/package/markstream-vue)
[![中文版](https://img.shields.io/badge/docs-中文文档-blue)](README.zh-CN.md)`,
mountOptions: {
global: {
stubs: {
'transition': false,
'transition-group': false,
},
},
},
assert: (wrapper) => {
const paragraph = wrapper.find('p.paragraph-node')
expect(paragraph.exists()).toBe(true)
expect(paragraph.element.tagName).toBe('P')
const childTagNames = Array.from(paragraph.element.children).map(child => child.tagName)
expect(childTagNames).toEqual(['A', 'A'])
const links = paragraph.findAll('a.link-node')
expect(links).toHaveLength(2)
expect(paragraph.findAll('span.text-node')).toHaveLength(0)
const images = paragraph.findAll('img')
expect(images).toHaveLength(2)
expect(links[0].find('img').exists()).toBe(true)
expect(links[1].find('img').exists()).toBe(true)
expect(links[1].attributes('href')).toBe('README.zh-CN.md')
},
},
{
name: 'inline code node',
markdown: 'Here is `const answer = 42` inline.',
expectedText: ['Here is', 'const answer = 42', 'inline.'],
assert: (wrapper) => {
const code = wrapper.find('code')
expect(code.exists()).toBe(true)
expect(code.text()).toBe('const answer = 42')
},
},
{
name: 'code block node',
markdown: '```ts\nexport const sum = (a: number, b: number) => a + b\n```',
props: { renderCodeBlocksAsPre: true },
expectedText: ['export const sum = (a: number, b: number) => a + b'],
assert: async (wrapper) => {
await flushAll()
const fallback = wrapper.find('pre code')
expect(fallback.exists()).toBe(true)
expect(fallback.text()).toContain('export const sum = (a: number, b: number) => a + b')
},
},
{
name: 'mermaid block node',
markdown: '```mermaid\ngraph LR;A-->B;\n```',
skipSnapshot: true,
assert: async (wrapper) => {
await flushAll()
await new Promise(resolve => setTimeout(resolve, 5000))
const mermaid = wrapper.find('._mermaid')
expect(mermaid.exists()).toBe(true)
// do not rely on exact toolbar SVG markup in snapshots (icons may
// render differently in different envs); just assert the wrapper
// contains the mermaid container
expect(mermaid.html()).toContain('_mermaid')
},
},
{
name: 'table node',
markdown: '| Name | Role |\n| --- | --- |\n| Alice | Developer |',
expectedText: ['Name', 'Role', 'Alice', 'Developer'],
assert: (wrapper) => {
const table = wrapper.find('table')
expect(table.exists()).toBe(true)
expect(table.findAll('tbody tr')).toHaveLength(1)
},
},
{
name: 'definition list node',
markdown: '',
props: {
nodes: [
{
type: 'definition_list',
raw: 'Term: Definition details',
items: [
{
type: 'definition_item',
raw: 'Term: Definition details',
term: [
{
type: 'text',
content: 'Term',
raw: 'Term',
},
],
definition: [
{
type: 'paragraph',
raw: 'Definition details',
children: [
{
type: 'text',
content: 'Definition details',
raw: 'Definition details',
},
],
},
],
},
],
},
],
},
expectedText: ['Term', 'Definition details'],
assert: (wrapper) => {
const dl = wrapper.find('dl')
expect(dl.exists()).toBe(true)
expect(dl.findAll('dt')).toHaveLength(1)
expect(dl.findAll('dd')).toHaveLength(1)
},
},
{
name: 'footnote nodes',
markdown: 'A footnote reference[^1].\n\n[^1]: Footnote explanation',
expectedText: ['A footnote reference', 'Footnote explanation'],
assert: (wrapper) => {
const footnoteBlock = wrapper.find('[id="fnref-1"]')
expect(footnoteBlock.exists()).toBe(true)
expect(footnoteBlock.text()).toBe('[1]')
const footerAnchor = wrapper.find('.footnote-anchor')
expect(footerAnchor.exists()).toBe(true)
},
},
{
name: 'footnote reference node',
markdown: '',
props: {
nodes: [
{
type: 'footnote_reference',
id: '1',
raw: '[^1]',
},
],
},
expectedText: [],
assert: (wrapper) => {
const reference = wrapper.find('sup.footnote-reference')
expect(reference.exists()).toBe(true)
expect(reference.text()).toContain('1')
},
},
{
name: 'admonition node',
markdown: '::: warning\nAdmonition content\n:::',
expectedText: 'Admonition content',
assert: (wrapper) => {
const admonition = wrapper.find('.admonition-warning')
expect(admonition.exists()).toBe(true)
},
},
{
name: 'thematic break node',
markdown: 'First section.\n\n---\n\nSecond section.',
expectedText: ['First section.', 'Second section.'],
assert: (wrapper) => {
const hr = wrapper.find('hr')
expect(hr.exists()).toBe(true)
},
},
{
name: 'hardbreak node',
markdown: 'Line one \nLine two',
expectedText: ['Line one', 'Line two'],
assert: (wrapper) => {
expect(wrapper.findAll('br.hard-break').length).toBeGreaterThan(0)
},
},
{
name: 'strong node',
markdown: 'This is **bold text**.',
expectedText: ['This is', 'bold text'],
assert: (wrapper) => {
const strong = wrapper.find('strong')
expect(strong.exists()).toBe(true)
expect(strong.text()).toBe('bold text')
},
},
{
name: 'emphasis node',
markdown: 'This is *italic text*.',
expectedText: ['This is', 'italic text'],
assert: (wrapper) => {
const em = wrapper.find('em')
expect(em.exists()).toBe(true)
expect(em.text()).toBe('italic text')
},
},
{
name: 'strikethrough node',
markdown: 'This is ~~deleted text~~.',
expectedText: ['This is', 'deleted text'],
assert: (wrapper) => {
const del = wrapper.find('s, del')
expect(del.exists()).toBe(true)
},
},
{
name: 'highlight node',
markdown: 'Use ==highlighted text== for emphasis.',
expectedText: ['Use', 'highlighted text', 'for emphasis.'],
assert: (wrapper) => {
const mark = wrapper.find('mark.highlight-node')
expect(mark.exists()).toBe(true)
},
},
{
name: 'insert node',
markdown: 'Here comes ++inserted text++ in the paragraph.',
expectedText: ['Here comes', 'inserted text', 'in the paragraph.'],
assert: (wrapper) => {
const ins = wrapper.find('ins.insert-node')
expect(ins.exists()).toBe(true)
},
},
{
name: 'subscript node',
markdown: 'Chemical formula H~2~O is familiar.',
expectedText: ['Chemical formula', 'H2O', 'is familiar.'],
assert: (wrapper) => {
const sub = wrapper.find('sub')
expect(sub.exists()).toBe(true)
expect(sub.text()).toBe('2')
},
},
{
name: 'superscript node',
markdown: 'Math uses x^2^ frequently.',
expectedText: ['Math uses', 'x2', 'frequently.'],
assert: (wrapper) => {
const sup = wrapper.find('sup')
expect(sup.exists()).toBe(true)
expect(sup.text()).toBe('2')
},
},
{
name: 'emoji node',
markdown: 'Smile with :smile: emoji.',
expectedText: ['Smile with', 'emoji.'],
assert: (wrapper) => {
const emoji = wrapper.find('span.emoji-node')
expect(emoji.exists()).toBe(true)
expect(emoji.text()).toBe('😄')
},
},
{
name: 'math inline node',
markdown: 'Einstein wrote $E=mc^2$.',
skipSnapshot: false,
// Accept either raw Dollar-delimited text or rendered KaTeX output
expectedText: ['Einstein wrote', 'E=mc^2'],
assert: async (wrapper) => {
await flushAll()
await flushAll()
const inline = wrapper.find('.math-inline')
if (inline.exists())
expect(wrapper.html()).toContain('katex')
else
expect(wrapper.text()).toContain('Einstein wrote $E=mc^2$.')
},
},
{
name: 'math block node',
markdown: '$$\na^2 + b^2 = c^2\n$$',
skipSnapshot: true,
// do not assert expectedText globally because math rendering is
// optional and may be handled asynchronously or by KaTeX which
// isn't guaranteed in the test env
expectedText: [],
assert: (wrapper) => {
const block = wrapper.find('.math-block')
// math rendering is optional (requires katex). Accept either
// a rendered KaTeX block or the raw LaTeX fallback
if (block.exists()) {
// prefer KaTeX output, but tolerate absence
const hasKaTeX = wrapper.html().includes('katex-display') || wrapper.html().includes('katex')
if (!hasKaTeX) {
expect(wrapper.text()).toContain('a^2 + b^2 = c^2')
}
}
},
},
{
name: 'reference node',
markdown: 'Cite research [1] for details.',
expectedText: ['Cite research', 'for details.'],
assert: (wrapper) => {
const reference = wrapper.find('span.reference-node')
expect(reference.exists()).toBe(true)
expect(reference.text()).toBe('1')
expect(reference.classes()).toContain('reference-node')
},
},
{
name: 'complex inline code with emphasis and multiple code spans',
markdown: '1. **整合冗余条款:** 将原`1-(5)`、`3-(3)`、`3-(4)`中关于“缴纳费用”和“告知法律状态变化”的核心义务合并,统一放入`1-(4)`“法律状态维护与通知义务”中,使其逻辑更集中,避免了在不同部分重复提及相似义务。',
expectedText: ['整合冗余条款', '1-(5)', '3-(3)', '3-(4)', '法律状态维护与通知义务'],
// assert removed: rely on expectedText assertions to validate parsing
},
]
for (const scenario of scenarios) {
it(`renders ${scenario.name}`, async () => {
const wrapper = await mountMarkdown(scenario.markdown, scenario.props, scenario.mountOptions)
try {
if (scenario.assert)
await scenario.assert(wrapper)
const textContent = normalizeText(wrapper.text())
if (Array.isArray(scenario.expectedText)) {
for (const snippet of scenario.expectedText)
expect(textContent).toContain(snippet)
}
else if (typeof scenario.expectedText === 'string' && scenario.expectedText.length > 0) {
expect(textContent).toContain(scenario.expectedText)
}
if (!scenario.skipSnapshot) {
const snapshotHtml = sanitizeSnapshotHtml(wrapper.html(), scenario.name)
expect(snapshotHtml).toMatchSnapshot()
}
}
finally {
wrapper.unmount()
}
}, 20000)
}
it('resizes table columns from header drag handles', async () => {
const wrapper = await mountMarkdown('| Name | Role |\n| --- | --- |\n| Alice | Developer |')
try {
const headers = wrapper.findAll('thead th')
expect(headers).toHaveLength(2)
const headerWidths = [120, 240]
headers.forEach((header, index) => {
Object.defineProperty(header.element, 'getBoundingClientRect', {
configurable: true,
value: () => ({
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: headerWidths[index],
x: 0,
y: 0,
toJSON: () => ({}),
}),
})
})
const handle = headers[0].find('.table-node__resize-handle')
expect(handle.exists()).toBe(true)
handle.element.dispatchEvent(new MouseEvent('pointerdown', {
bubbles: true,
button: 0,
clientX: 120,
}))
window.dispatchEvent(new MouseEvent('pointermove', {
bubbles: true,
clientX: 180,
}))
window.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
await flushAll()
const columns = wrapper.findAll('col')
expect(columns).toHaveLength(2)
expect(columns[0].attributes('style')).toContain('width: 180px')
expect(columns[1].attributes('style')).toContain('width: 180px')
}
finally {
wrapper.unmount()
}
})
it('allows overriding `list_item` via customComponents', async () => {
const scopeId = 'custom-components-list-item'
const CustomListItem = defineComponent({
name: 'CustomListItem',
props: {
node: { type: Object, required: true },
indexKey: [String, Number],
value: Number,
customId: String,
typewriter: Boolean,
},
setup(props) {
return () => h('li', { 'class': 'custom-list-item', 'data-value': props.value == null ? '' : String(props.value) }, [
h(MarkdownRender, {
nodes: (props.node as any).children || [],
customId: props.customId,
indexKey: `custom-list-item-${String(props.indexKey ?? '')}`,
typewriter: props.typewriter,
batchRendering: false,
}),
])
},
})
setCustomComponents(scopeId, { list_item: CustomListItem })
try {
const wrapper = await mountMarkdown('1. First entry\n2. Second entry', { customId: scopeId, final: true })
try {
const items = wrapper.findAll('li.custom-list-item')
expect(items).toHaveLength(2)
expect(items[0].attributes('data-value')).toBe('1')
expect(items[1].attributes('data-value')).toBe('2')
expect(normalizeText(wrapper.text())).toMatch(/First entry\s*Second entry/)
}
finally {
wrapper.unmount()
}
}
finally {
removeCustomComponents(scopeId)
}
})
it('replays a fade animation when a streamed non-code node updates in place', async () => {
const wrapper = await mountMarkdown('Hello')
try {
const nodeContent = () => wrapper.find('[data-node-index="0"] .node-content')
expect(nodeContent().attributes('style') ?? '').toBe('')
expect(wrapper.find('.text-node-stream-delta').exists()).toBe(false)
await wrapper.setProps({ content: 'Hello world' })
await flushAll()
expect(nodeContent().attributes('style') ?? '').toBe('')
const firstDelta = wrapper.find('.text-node-stream-delta')
expect(firstDelta.exists()).toBe(true)
expect(firstDelta.text()).toBe('world')
expect(normalizeText(wrapper.text())).toContain('Hello world')
await wrapper.setProps({ content: 'Hello world again' })
await flushAll()
expect(nodeContent().attributes('style') ?? '').toBe('')
const secondDelta = wrapper.find('.text-node-stream-delta')
expect(secondDelta.exists()).toBe(true)
expect(secondDelta.text()).toBe('again')
expect(normalizeText(wrapper.text())).toContain('Hello world again')
}
finally {
wrapper.unmount()
}
})
it('renders repeated custom components without slot content reuse', async () => {
const scopeId = 'custom-components-repeat'
const NewQuestion = defineComponent({
name: 'NewQuestion',
setup(_, { slots }) {
return () => h('div', { class: 'new-question' }, slots.default?.())
},
})
const SubQuestion = defineComponent({
name: 'SubQuestion',
setup(_, { slots }) {
return () => h('div', { class: 'sub-question' }, slots.default?.())
},
})
setCustomComponents(scopeId, {
'new-question': NewQuestion,
'sub-question': SubQuestion,
})
try {
const markdown = `<new-question>A</new-question>
<sub-question>
B
</sub-question>
<sub-question>
C
</sub-question>`
const wrapper = await mountMarkdown(markdown, {
customId: scopeId,
final: true,
})
try {
expect(normalizeText(wrapper.text())).toContain('A B C')
}
finally {
wrapper.unmount()
}
}
finally {
removeCustomComponents(scopeId)
}
})
it('renders nested repeated custom components without content bleed', async () => {
const scopeId = 'custom-components-nested-repeat'
const NewQuestion = defineComponent({
name: 'NewQuestion',
setup(_, { slots }) {
return () => h('div', { class: 'new-question' }, slots.default?.())
},
})
const SubQuestion = defineComponent({
name: 'SubQuestion',
setup(_, { slots }) {
return () => h('div', { class: 'sub-question' }, slots.default?.())
},
})
setCustomComponents(scopeId, {
'new-question': NewQuestion,
'sub-question': SubQuestion,
})
try {
const markdown = `<sub-question>
<new-question>A</new-question>
<sub-question>Inner-1</sub-question>
</sub-question>
<sub-question>
<new-question>B</new-question>
<sub-question>Inner-2</sub-question>
</sub-question>`
const wrapper = await mountMarkdown(markdown, { customId: scopeId, final: true })
try {
expect(normalizeText(wrapper.text())).toMatch(/A\s*Inner-1\s*B\s*Inner-2/)
}
finally {
wrapper.unmount()
}
}
finally {
removeCustomComponents(scopeId)
}
})
it('renders repeated custom components with different attrs and explicit keys', async () => {
const scopeId = 'custom-components-attrs-repeat'
const SubQuestion = defineComponent({
name: 'SubQuestion',
setup(_, { slots, attrs }) {
const id = attrs['data-id'] == null ? '' : String(attrs['data-id'])
return () => h('div', { class: 'sub-question' }, [
h('span', { class: 'sub-question__id' }, id),
slots.default?.(),
])
},
})
setCustomComponents(scopeId, {
'sub-question': SubQuestion,
})
try {
const markdown = `<sub-question key="k1" data-id="1">B</sub-question>
<sub-question key="k2" data-id="2">C</sub-question>`
const wrapper = await mountMarkdown(markdown, { customId: scopeId, final: true })
try {
expect(normalizeText(wrapper.text())).toMatch(/1\s*B\s*2\s*C/)
}
finally {
wrapper.unmount()
}
}
finally {
removeCustomComponents(scopeId)
}
})
it('renders custom components interleaved with markdown blocks', async () => {
const scopeId = 'custom-components-mixed-markdown'
const NewQuestion = defineComponent({
name: 'NewQuestion',
setup(_, { slots }) {
return () => h('div', { class: 'new-question' }, slots.default?.())
},
})
const SubQuestion = defineComponent({
name: 'SubQuestion',
setup(_, { slots }) {
return () => h('div', { class: 'sub-question' }, slots.default?.())
},
})
setCustomComponents(scopeId, {
'new-question': NewQuestion,
'sub-question': SubQuestion,
})
try {
const markdown = `<div><new-question>A</new-question></div>
Normal paragraph between.
- item-1
- item-2
<div><sub-question>B</sub-question></div>
Another paragraph.
<div><sub-question>C</sub-question></div>`
const wrapper = await mountMarkdown(markdown, { customId: scopeId, final: true })
try {
const text = normalizeText(wrapper.text())
expect(text).toContain('A')
expect(text).toContain('Normal paragraph between.')
expect(text).toMatch(/item-1\s*item-2/)
expect(text).toMatch(/A[^B]*B[\s\S]*C/)
}
finally {
wrapper.unmount()
}
}
finally {
removeCustomComponents(scopeId)
}
})
it('renders repeated custom components mixed with standard HTML inside one block', async () => {
const scopeId = 'custom-components-mixed-html'
const SubQuestion = defineComponent({
name: 'SubQuestion',
setup(_, { slots }) {
return () => h('div', { class: 'sub-question' }, slots.default?.())
},
})
setCustomComponents(scopeId, {
'sub-question': SubQuestion,
})
try {
const markdown = `<div>
<sub-question>First <strong>X</strong></sub-question>
<span>middle</span>
<sub-question>Second <em>Y</em></sub-question>
</div>`
const wrapper = await mountMarkdown(markdown, { customId: scopeId, final: true })
try {
expect(normalizeText(wrapper.text())).toMatch(/First\s*X\s*middle\s*Second\s*Y/)
}
finally {
wrapper.unmount()
}
}
finally {
removeCustomComponents(scopeId)
}
})
it('passes node props to custom components when using nodes prop with customHtmlTags', async () => {
const scopeId = 'custom-nodes-prop-thinking'
const ThinkingNode = defineComponent({
name: 'ThinkingNode',
props: {
node: { type: Object, default: () => ({ loading: false, content: '' }) },
},
setup(props) {
return () => h('div', { class: 'thinking-node' }, props.node.content)
},
})
setCustomComponents(scopeId, {
thinking: (props: any) => h(ThinkingNode, props),
})
try {
// Pre-parsed nodes as if parsed without customHtmlTags – the thinking
// tag is still an html_block with tag:"thinking".
const nodes = [
{
type: 'html_block',
tag: 'thinking',
content: '<thinking>I am thinking</thinking>',
raw: '<thinking>I am thinking</thinking>',
loading: false,
},
{
type: 'paragraph',
children: [{ type: 'text', content: 'Hello', raw: 'Hello' }],
},
]
const wrapper = mount(MarkdownRender, {
props: {
nodes,
final: true,
customId: scopeId,
customHtmlTags: ['thinking'],
},
})
await flushAll()
try {
const text = normalizeText(wrapper.text())
expect(text).toContain('I am thinking')
expect(text).toContain('Hello')
// The custom component should be rendered (not the default HtmlBlockNode)
expect(wrapper.find('.thinking-node').exists()).toBe(true)
}
finally {
wrapper.unmount()
}
}
finally {
removeCustomComponents(scopeId)
}
})
})
@@ -0,0 +1,30 @@
import { parseFenceToken } from 'stream-markdown-parser'
import { describe, expect, it } from 'vitest'
describe('fence parser trailing fence cleanup', () => {
it('removes a trailing line that only contains backticks from token.content', () => {
const token: any = {
type: 'fence',
info: 'ts',
content: 'const a = 1\n```',
map: [0, 2],
}
const node = parseFenceToken(token as any)
expect(node).toBeDefined()
expect((node as any).code).toBe('const a = 1')
expect((node as any).raw).toBe('const a = 1')
})
it('keeps legitimate content that contains backticks not on their own line', () => {
const token: any = {
type: 'fence',
info: 'text',
content: 'console.log(\'`inline`)\n', // backtick inside code, not a fence line
map: [0, 2],
}
const node = parseFenceToken(token as any)
expect((node as any).code).toBe('console.log(\'`inline`)\n')
expect((node as any).raw).toBe('console.log(\'`inline`)\n')
})
})
@@ -0,0 +1,40 @@
import { findMatchingClose } from 'stream-markdown-parser'
import { describe, expect, it } from 'vitest'
describe('findMatchingClose', () => {
it('finds matching close for nested parentheses with escaped braces', () => {
const src = '\\((\\operatorname{span}\\\\{\\boldsymbol{\\alpha}\\\\})^\\perp\\)'
// search for the open after the leading '\\('
const openIdx = src.indexOf('\\(')
// start looking after the opener
const found = findMatchingClose(src, openIdx + 2, '\\(', '\\)')
expect(found).toBeGreaterThan(openIdx)
expect(src.slice(found, found + 2)).toBe('\\)')
})
it('finds matching close for nested parentheses with escaped braces -1', () => {
const src = '"总之,\\(\\boldsymbol{\\alpha}^T \\boldsymbol{\\beta} = 0\\)"'
// search for the open after the leading '\\('
const openIdx = src.indexOf('\\(')
// start looking after the opener
const found = findMatchingClose(src, openIdx + 2, '\\(', '\\)')
console.log({ found, openIdx })
expect(found).toBeGreaterThan(openIdx)
expect(src.slice(found, found + 2)).toBe('\\)')
})
it('finds matching close for $$ delimiter', () => {
const src = '$$ E = mc^2 $$ and more'
const openIdx = src.indexOf('$$')
const found = findMatchingClose(src, openIdx + 2, '$$', '$$')
expect(src.slice(found, found + 2)).toBe('$$')
})
it('returns -1 if no close found', () => {
const src = '\\( unclosed math'
const openIdx = src.indexOf('\\(')
const found = findMatchingClose(src, openIdx + 2, '\\(', '\\)')
expect(found).toBe(-1)
})
})
@@ -0,0 +1,332 @@
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
import { describe, expect, it } from 'vitest'
describe('fixStrongTokens plugin (parse-token assertions)', () => {
it('produces strong token around inner underscore for `a __b_c__ d`', () => {
const md = getMarkdown('t')
const content = 'a __b_c__ d'
const nodes = parseMarkdownToStructure(content, md)
// top-level should be a paragraph
const para = nodes[0] as any
expect(para).toBeDefined()
expect(para.type).toBe('paragraph')
const strong = para.children?.find((c: any) => c.type === 'strong')
expect(strong).toBeDefined()
const text = strong.children?.[0]
expect(text).toBeDefined()
expect(text.type).toBe('text')
expect(text.content).toBe('b_c')
})
it('keeps malformed emphasis with an invalid closer as text', () => {
const md = getMarkdown('t')
const content = 'this is *a test * with unmatched star'
const nodes = parseMarkdownToStructure(content, md)
// basic sanity: nodes array exists and contains at least one paragraph/inline-derived node
const emphasis = nodes[0].children?.find((c: any) => c.type === 'emphasis')
expect(emphasis).toBeUndefined()
expect(nodes[0].children?.[0].type).toBe('text')
expect(nodes[0].children?.[0].content).toBe(content)
})
it('parses strong around inline HTML tag: `**<font color="red">hi</font>**`', () => {
const md = getMarkdown('t')
const content = '**<font color="red">hi</font>**'
const nodes = parseMarkdownToStructure(content, md)
const para = nodes[0] as any
expect(para).toBeDefined()
expect(para.type).toBe('paragraph')
const strong = para.children?.find((c: any) => c.type === 'strong')
expect(strong).toBeDefined()
// inner should contain an html_inline node (font) with text child
const html = strong.children?.find((c: any) => c.type === 'html_inline' && c.tag === 'font')
expect(html).toBeDefined()
expect(html.loading).toBe(false)
expect(html.autoClosed).toBeFalsy()
const innerText = html.children?.find((c: any) => c.type === 'text')
expect(innerText?.content).toBe('hi')
})
it('should not parse escaped asterisks as strong: `需方:\\*\\*\\*\\*\\*\\*有限公司`', () => {
const md = getMarkdown('t')
const content = '需方:\\*\\*\\*\\*\\*\\*有限公司'
const nodes = parseMarkdownToStructure(content, md)
const para = nodes[0] as any
expect(para).toBeDefined()
expect(para.type).toBe('paragraph')
// Should not contain any strong nodes - escaped asterisks should remain as text
const strongNodes = para.children?.filter((c: any) => c.type === 'strong')
expect(strongNodes?.length).toBe(0)
// Should be a single text node with the literal asterisks
const text = para.children?.[0]
expect(text?.type).toBe('text')
expect(text?.content).toBe('需方:******有限公司')
})
it('should not parse escaped asterisks as strong: `\\*\\*\\*\\*`', () => {
const md = getMarkdown('t')
const content = '\\*\\*\\*\\*'
const nodes = parseMarkdownToStructure(content, md)
const para = nodes[0] as any
expect(para).toBeDefined()
expect(para.type).toBe('paragraph')
// Should not contain any strong nodes
const strongNodes = para.children?.filter((c: any) => c.type === 'strong')
expect(strongNodes?.length).toBe(0)
// Should be a single text node
const text = para.children?.[0]
expect(text?.type).toBe('text')
expect(text?.content).toBe('****')
})
it('should parse normal strong correctly: `**bold**`', () => {
const md = getMarkdown('t')
const content = '**bold**'
const nodes = parseMarkdownToStructure(content, md)
const para = nodes[0] as any
expect(para).toBeDefined()
expect(para.type).toBe('paragraph')
// Should contain a strong node
const strong = para.children?.find((c: any) => c.type === 'strong')
expect(strong).toBeDefined()
expect(strong.children?.[0].content).toBe('bold')
})
it('should keep strong intact around inline math in ordered list', () => {
const md = getMarkdown('t')
const content = '1. **化简集合\\( P \\)并求补集**:\n测试啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊'
const nodes = parseMarkdownToStructure(content, md, { final: true })
const list = nodes[0] as any
expect(list?.type).toBe('list')
expect(list.ordered).toBe(true)
const li = list.items?.[0]
expect(li?.type).toBe('list_item')
const para = li.children?.[0]
expect(para?.type).toBe('paragraph')
const strongs = (para.children ?? []).filter((c: any) => c.type === 'strong')
expect(strongs.length).toBe(1)
const strong = strongs[0]
const strongText = (strong.children ?? [])
.filter((c: any) => c.type === 'text')
.map((c: any) => c.content)
.join('')
expect(strongText).toBe('化简集合并求补集')
expect((strong.children ?? []).some((c: any) => c.type === 'math_inline')).toBe(true)
const afterText = (para.children ?? []).find((c: any) => c.type === 'text') as any
expect(afterText?.content).toBe(':\n测试啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊')
})
describe('escaped characters ($, [, -, _) should be treated as literal text', () => {
it('should treat escaped dollar signs as text: `\\$\\$`', () => {
const md = getMarkdown('t')
const content = '\\$\\$'
const nodes = parseMarkdownToStructure(content, md)
const para = nodes[0] as any
expect(para).toBeDefined()
expect(para.type).toBe('paragraph')
const text = para.children?.[0]
expect(text?.type).toBe('text')
expect(text?.content).toBe('$$')
})
it('should treat escaped dollar signs with content as text: `\\$x\\$`', () => {
const md = getMarkdown('t')
const content = '\\$x\\$'
const nodes = parseMarkdownToStructure(content, md)
const para = nodes[0] as any
expect(para).toBeDefined()
expect(para.type).toBe('paragraph')
const text = para.children?.[0]
expect(text?.type).toBe('text')
expect(text?.content).toBe('$x$')
})
it('should treat escaped brackets as text: `\\[text\\]`', () => {
const md = getMarkdown('t')
const content = '\\[text\\]'
const nodes = parseMarkdownToStructure(content, md)
const para = nodes[0] as any
expect(para).toBeDefined()
expect(para.type).toBe('paragraph')
const text = para.children?.[0]
expect(text?.type).toBe('text')
expect(text?.content).toBe('[text]')
})
it('should treat escaped hyphen as text: `\\-`', () => {
const md = getMarkdown('t')
const content = '\\-'
const nodes = parseMarkdownToStructure(content, md)
const para = nodes[0] as any
expect(para).toBeDefined()
expect(para.type).toBe('paragraph')
const text = para.children?.[0]
expect(text?.type).toBe('text')
expect(text?.content).toBe('-')
})
it('should treat escaped underscore as text: `\\_`', () => {
const md = getMarkdown('t')
const content = '\\_'
const nodes = parseMarkdownToStructure(content, md)
const para = nodes[0] as any
expect(para).toBeDefined()
expect(para.type).toBe('paragraph')
const text = para.children?.[0]
expect(text?.type).toBe('text')
expect(text?.content).toBe('_')
})
it('should treat escaped underscores in text as text: `a \\_ b`', () => {
const md = getMarkdown('t')
const content = 'a \\_ b'
const nodes = parseMarkdownToStructure(content, md)
const para = nodes[0] as any
expect(para).toBeDefined()
expect(para.type).toBe('paragraph')
const text = para.children?.[0]
expect(text?.type).toBe('text')
expect(text?.content).toBe('a _ b')
})
it('should not parse escaped double underscore as strong: `\\_\\_text\\_\\_`', () => {
const md = getMarkdown('t')
const content = '\\_\\_text\\_\\_'
const nodes = parseMarkdownToStructure(content, md)
const para = nodes[0] as any
expect(para).toBeDefined()
expect(para.type).toBe('paragraph')
// Should not contain strong nodes
const strongNodes = para.children?.filter((c: any) => c.type === 'strong')
expect(strongNodes?.length).toBe(0)
const text = para.children?.[0]
expect(text?.type).toBe('text')
expect(text?.content).toBe('__text__')
})
it('should treat escaped backslash as text: `\\\\`', () => {
const md = getMarkdown('t')
const content = '\\\\'
const nodes = parseMarkdownToStructure(content, md)
const para = nodes[0] as any
expect(para).toBeDefined()
expect(para.type).toBe('paragraph')
const text = para.children?.[0]
expect(text?.type).toBe('text')
expect(text?.content).toBe('\\')
})
// Mixed escaped and unescaped asterisks
// Note: When escaped asterisks are followed by unescaped ones in the same line,
// the behavior depends on whether they are separated by text tokens
describe('mixed escaped and unescaped asterisks', () => {
it('should parse escaped asterisks at start followed by real strong: `\\*\\*escaped\\*\\* **real**`', () => {
const md = getMarkdown('t')
const content = '\\*\\*escaped\\*\\* **real**'
const nodes = parseMarkdownToStructure(content, md)
const para = nodes[0] as any
expect(para).toBeDefined()
expect(para.type).toBe('paragraph')
// First part: escaped asterisks as text
const text1 = para.children?.[0]
expect(text1?.type).toBe('text')
expect(text1?.content).toBe('**escaped** ')
// Second part: actual strong
const strong = para.children?.[1]
expect(strong?.type).toBe('strong')
expect(strong.children?.[0].content).toBe('real')
})
it('should handle escaped asterisks in middle with text separation: `prefix \\*\\* mid \\*\\* suffix **real**`', () => {
const md = getMarkdown('t')
const content = 'prefix \\*\\* mid \\*\\* suffix **real**'
const nodes = parseMarkdownToStructure(content, md)
const para = nodes[0] as any
expect(para).toBeDefined()
expect(para.type).toBe('paragraph')
// KNOWN LIMITATION: When escaped asterisks are followed by text in the same
// token stream, they may still be parsed as strong. This is because text_special
// tokens (escaped asterisks) are merged with subsequent text tokens, and the
// merged content may then match the strong pattern.
// Current behavior: creates strong nodes for " mid " and " suffix "
const strongNodes = para.children?.filter((c: any) => c.type === 'strong')
expect(strongNodes?.length).toBeGreaterThan(0)
// The last strong should contain 'real'
const lastStrong = strongNodes[strongNodes.length - 1]
expect(lastStrong?.children?.[0].content).toBe('real')
})
it('should handle multiple escaped asterisks groups: `\\*\\*\\*\\* and \\*\\*`', () => {
const md = getMarkdown('t')
const content = '\\*\\*\\*\\* and \\*\\*'
const nodes = parseMarkdownToStructure(content, md)
const para = nodes[0] as any
expect(para).toBeDefined()
expect(para.type).toBe('paragraph')
// Should be all text, no strong nodes
const strongNodes = para.children?.filter((c: any) => c.type === 'strong')
expect(strongNodes?.length).toBe(0)
const text = para.children?.[0]
expect(text?.type).toBe('text')
expect(text?.content).toBe('**** and **')
})
it('should handle escaped asterisks at end: `text \\*\\*`', () => {
const md = getMarkdown('t')
const content = 'text \\*\\*'
const nodes = parseMarkdownToStructure(content, md)
const para = nodes[0] as any
expect(para).toBeDefined()
expect(para.type).toBe('paragraph')
const text = para.children?.[0]
expect(text?.type).toBe('text')
expect(text?.content).toBe('text **')
})
})
})
})
@@ -0,0 +1,3 @@
::: warning
This is a warning admonition with **markdown** inside.
:::
+4
View File
@@ -0,0 +1,4 @@
- [x] Completed task
- [ ] Incomplete task
Regular list item
+11
View File
@@ -0,0 +1,11 @@
```diff
--- a/file.txt
+++ b/file.txt
@@
- old line
+ new line
```
```js
console.log('hello world')
```
@@ -0,0 +1,3 @@
A line with an escaped bracket: \[not a link\]
And a literal backslash \\ and more text.
@@ -0,0 +1,10 @@
```diff js
@@
- old
+ new
```
```python
# regular code block
print('ok')
```
@@ -0,0 +1,3 @@
This is a sentence with a footnote[^1].
[^1]: This is the footnote text.
+7
View File
@@ -0,0 +1,7 @@
# Heading 1
A short paragraph under heading 1.
## Heading 2
Another paragraph with **strong** and *emphasis*.
@@ -0,0 +1,118 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>卷绕设备异常分析处理</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<style>
table {
border-collapse: collapse;
width: 100%;
margin: 20px auto;
border: 1px solid #ddd;
font-family: Arial, sans-serif;
}
th, td {
border: 1px solid #ddd;
padding: 8px 12px;
text-align: left;
}
th {
background-color: #f2f2f2;
font-weight: bold;
}
tr:nth-child(even) {
background-color: #f9f9f9;
}
.chart-container {
width: 800px;
height: 400px;
margin: 30px auto;
border: 1px solid #ddd;
padding: 10px;
background-color: #fff;
}
h2 {
text-align: center;
color: #333;
}
</style>
</head>
<body>
<h2>卷绕设备异常分析处理汇总</h2>
<table>
<tr>
<th>异常类型</th>
<th>问题描述</th>
<th>排查解决步骤/原因</th>
</tr>
<tr>
<td>1</td>
<td>电芯X-ray包覆不良与那些因素有关</td>
<td>1. 纠偏感应器有脏污,纠偏感应器有无信号。<br>2. 纠偏过辊卡死或窜动。<br>3. 纠偏靠辊和最终靠辊平行度不平行,三角架内过辊的精度异常。<br>4. 正负极入料吹气异常。<br>5. 卷针插针时插隔膜,卷针前后有窜动。<br>6. 卷针内夹针夹不紧隔膜。<br>7. 卷针左右晃动,卷针和插针丝杆磨损严重。<br>8. 极片来料波浪太大,卷绕时极片抖动。</td>
</tr>
<tr>
<td>2</td>
<td>电芯X-ray包覆不良排查解决</td>
<td>1. 确认正负极极片物料是否有异常。<br>2. 确认极片和隔膜张力是否正常<br>3. 确认纠偏压辊水平,纠偏压辊压力及纠偏压辊行程。<br>4. 确认纠偏方向,光电设置是否正确。<br>5. 确认卷绕收尾靠辊压力及收尾位置是否合适。<br>6. 确认插片角度是否有偏差,纠偏料线是否同一基准。<br>7. 确认卷针与锁膜头同心度,卷针铁氟龙有无破损。<br>8. 确认正负极切断吹气及入料吹气是否合适。</td>
</tr>
<tr>
<td>3</td>
<td>卷针运转时不稳定晃动,手动左右旋转有间隙</td>
<td>1. 卷针螺丝是否松动。<br>2. 卷针前锁膜头是否与卷针匹配,顺畅,检查同心度。<br>3. 卷针是否与卷针座同心,卷针座安装固定螺丝是否紧固。<br>4. 检查卷针尾座与花键轴连接是否松动。</td>
</tr>
<tr>
<td>4</td>
<td>间距不良排查解决</td>
<td>1. 电芯分针查看间距。<br>2. 确认电芯极片头部是否顶到电芯内圈边缘。<br>3. 对比间距不良电芯和正常电芯切位。<br>4. 确认电芯下料有无拔针现象。<br>5. 确认入料压辊配合及压力。<br>6. 确认极耳检测传感器安装位置及设置是否正确。<br>7. 确认投入夹片是否能加紧极片。<br>8. 确认极片裁切时是否极片不平整或有拉伸。<br>9. 确认卷针夹针间隙及内夹针。<br>10. 确认卷针下料夹紧压力<br>11. 确认卷绕减速点设置和卷绕速度是否合理。<br>12. 确认插片电机,切刀电机联轴器是否紧固。</td>
</tr>
<tr>
<td>5</td>
<td>电芯隔膜锥形调试解决</td>
<td>1. 确认隔膜纠偏有无异常波动。<br>2. 隔膜过辊精度有无明显歪斜,入料靠辊是否水平。<br>3. 确认隔膜来料是否不良波浪边。<br>4. 检查A、B、C三根卷针前位位置是否统一位置,卷针前位磁铁块位置是否统一。<br>5. 确认卷针与推卷针电机前位位置间隙。<br>6. 确认转塔翻位到贴胶位时磁铁块有无吸住卷针。<br>7. 确认卷绕位电芯卷绕时隔膜抖动是否过大,隔膜张力设置是否合理。<br>8. 检查隔膜过辊有无卡点造成隔膜卷绕时阻力过大。</td>
</tr>
<tr>
<td>6</td>
<td>极片入料打折</td>
<td>1. 入料吹气不良,入料角度与吹气角度不匹配<br>2. 入料吹气大小及时间设置是否匹配。<br>3. 入料前极片弯曲打折。<br>4. 正负极入料时极片切刀吸尘真空过大。<br>5. 极片入料卡在切刀口。<br>6. 检查入料部件极片入料时有无异物干涉。</td>
</tr>
<tr>
<td>7</td>
<td>收尾贴胶打皱</td>
<td>1. 检查胶带切刀是否异常。<br>2. 检查吸胶辊表面有无异物,吸胶角度是否正确。<br>3. 确认吸胶辊预留吸孔是否合理,封孔胶带是否有破损。<br>4. 确认贴胶辊与卷针水平,贴胶圈数是否合理。<br>5. 确认贴胶辊电机扭矩设置是否合理。</td>
</tr>
<tr>
<td>8</td>
<td>下料拔针不良</td>
<td>1. 检查下料夹爪与卷针角度。<br>2. 卷针表面是否有脏污。<br>3. 下料时速度过快,下料夹爪有无晃动。<br>4. 下料时卷针卷针有无解锁。<br>5. 卷针下料夹紧电芯压力。<br>6. 检查卷针内夹针缝隙大小,及内针弹性有无异常。</td>
</tr>
</table>
<div class="chart-container">
<div id="exceptionChart"></div>
</div>
<script>
// 初始化ECharts图表
var chartDom = document.getElementById('exceptionChart');
var myChart = echarts.init(chartDom);
// 异常类型分布数据(按类型统计条目数量)
var option = {
title: { text: '异常类型分布统计' },
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', data: ['1', '2', '3', '4', '5', '6', '7', '8'], name: '异常类型' },
yAxis: { type: 'value', name: '问题数量' },
series: [{
name: '异常数量',
type: 'bar',
data: [8, 8, 4, 12, 8, 6, 5, 6]
}]
};
option && myChart.setOption(option);
</script>
</body>
</html>
@@ -0,0 +1,3 @@
Here is an image: ![alt text](https://example.com/image.png)
And a link: [example](https://example.com)
@@ -0,0 +1,11 @@
```infographic
infographic list-row-simple-horizontal-arrow
data
items
- label 步骤 1
desc 开始
- label 步骤 2
desc 进行中
- label 步骤 3
desc 完成
```
+7
View File
@@ -0,0 +1,7 @@
Inline math (\alpha + \beta) and another form (x_i).
$$
E = mc^2
$$
A line with parentheses that should not break: (not-math)
+5
View File
@@ -0,0 +1,5 @@
```mermaid
graph TD
A-->B
B-->C
```
@@ -0,0 +1,9 @@
1. Item one
- nested bullet with **bold**
1. nested ordered inside bullet
- another nested
2. Item two
- Top bullet
1. nested ordered inside top bullet
- triple nested bullet
@@ -0,0 +1,62 @@
import type { NodeComponentProps } from 'markstream-react'
import MarkdownRender, { MarkdownCodeBlockNode, setCustomComponents } from 'markstream-react'
import 'markstream-react/index.css'
function rewriteDocsUrl(url: string) {
if (url.startsWith('/docs/'))
return `https://markstream.example.com${url}`
return url
}
function CustomHeading({ node, ctx, renderNode, indexKey }: NodeComponentProps<any>) {
const Tag = `h${node.level || 1}` as keyof JSX.IntrinsicElements
return (
<Tag className="docs-title">
{node.children?.map((child: any, i: number) =>
renderNode && ctx
? renderNode(child, `${String(indexKey)}-heading-${i}`, ctx)
: null,
)}
</Tag>
)
}
function CustomLink({ node, ctx, renderNode, indexKey }: NodeComponentProps<any>) {
const href = rewriteDocsUrl(node.href)
return (
<a href={href} target="_blank" rel="noreferrer">
{node.children?.map((child: any, i: number) =>
renderNode && ctx
? renderNode(child, `${String(indexKey)}-link-${i}`, ctx)
: null,
)}
</a>
)
}
setCustomComponents('migration-demo', {
heading: CustomHeading,
link: CustomLink,
code_block: ({ node, isDark, ctx }: any) => (
<MarkdownCodeBlockNode
node={node}
isDark={isDark}
stream={ctx?.codeBlockStream}
{...(ctx?.codeBlockProps || {})}
/>
),
})
export function AdvancedArticle({ markdown }: { markdown: string }) {
return (
<MarkdownRender
customId="migration-demo"
content={markdown}
/>
)
}
// If the previous component depended on `allowedElements`, filter the parsed
// node tree before render. There is no direct allowlist prop on markstream-react.
@@ -0,0 +1,6 @@
import MarkdownRender from 'markstream-react'
import 'markstream-react/index.css'
export function BasicArticle({ markdown }: { markdown: string }) {
return <MarkdownRender content={markdown} />
}
@@ -0,0 +1,71 @@
import Markdown from 'react-markdown'
import rehypeRaw from 'rehype-raw'
import remarkGfm from 'remark-gfm'
function rewriteDocsUrl(url: string) {
if (url.startsWith('/docs/'))
return `https://markstream.example.com${url}`
return url
}
export function AdvancedArticle({ markdown }: { markdown: string }) {
return (
<Markdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
allowedElements={[
'h1',
'h2',
'p',
'a',
'code',
'pre',
'ul',
'ol',
'li',
'strong',
'em',
'table',
'thead',
'tbody',
'tr',
'th',
'td',
]}
urlTransform={rewriteDocsUrl}
components={{
h1({ children }) {
return <h1 className="docs-title">{children}</h1>
},
a({ href, children }) {
return (
<a href={href} target="_blank" rel="noreferrer">
{children}
</a>
)
},
code({ className, children, ...rest }) {
const isBlock = /language-/.test(className || '')
if (isBlock) {
return (
<pre className="docs-code">
<code className={className} {...rest}>
{children}
</code>
</pre>
)
}
return (
<code className="inline-code" {...rest}>
{children}
</code>
)
},
}}
>
{markdown}
</Markdown>
)
}
@@ -0,0 +1,5 @@
import Markdown from 'react-markdown'
export function BasicArticle({ markdown }: { markdown: string }) {
return <Markdown>{markdown}</Markdown>
}
@@ -0,0 +1,53 @@
# Demo Skill Output
This folder is an isolated demo for the `react-markdown-to-markstream-react` skill.
The main repository is not a real `react-markdown` consumer, so this fixture exists to show what a meaningful audit and migration result would look like.
## Audit Summary
- Found 2 `react-markdown` call sites in this demo fixture.
- Classified 1 call site as direct migration and 1 as advanced/plugin-heavy migration.
- Safe direct migrations were applied in `after-basic.tsx` and `after-advanced.tsx`.
- Manual review is still required for HTML policy and element allowlisting.
## Call Sites
| File | Classification | Why |
|---|---|---|
| `before-basic.tsx` | direct | Plain `<Markdown>{markdown}</Markdown>` usage |
| `before-advanced.tsx` | renderer-custom + plugin-heavy + security-heavy | Uses `components`, `remark-gfm`, `rehype-raw`, `allowedElements`, and `urlTransform` |
## Direct Mappings Applied
| `react-markdown` usage | `markstream-react` migration |
|---|---|
| `children` | `content` |
| `components.h1` | `setCustomComponents('migration-demo', { heading })` with `node.level` |
| `components.a` | custom `link` renderer |
| `components.code` | custom `code_block` renderer with `MarkdownCodeBlockNode` |
| `urlTransform` | link rewrite logic moved into the custom `link` renderer |
## Manual Review Items
### `rehypeRaw`
`markstream-react` does not expose a public `rehype` stage.
If the old app relied on `rehypeRaw` for standard HTML rendering, verify the new HTML behavior.
If it relied on trusted custom tags, prefer `customHtmlTags` plus `setCustomComponents`.
### `allowedElements`
There is no direct `allowedElements` prop in `markstream-react`.
If exact allowlisting still matters, filter the parsed node tree before rendering.
### Security parity
The new renderer sanitizes blocked tags, dangerous event-handler attributes, and unsafe URLs in built-in HTML renderers, but it is still not a byte-for-byte replacement for a repo-specific `rehype` sanitization stack.
## Suggested User-Facing Skill Result
1. Replace `react-markdown` with `markstream-react` for direct call sites first.
2. Convert HTML-tag-based `components` overrides into node-type-based `setCustomComponents`.
3. Remove `remark-gfm` only after checking rendered tables, task lists, and other edge cases.
4. Revisit any `rehype`-based HTML and security logic before claiming migration completeness.
5. Adopt `nodes` + `parseMarkdownToStructure` later only if the app actually needs streaming updates.
+6
View File
@@ -0,0 +1,6 @@
| A | B |
| - | - |
| 1 | 2 |
| 3 | 4 |
A paragraph after the table.
@@ -0,0 +1,6 @@
```js
const a = 1
```
``
Some text after an extra backtick line.
@@ -0,0 +1,5 @@
```js
console.log('unclosed fence')
# Following heading that might be part of the fence
```
@@ -0,0 +1,5 @@
This line has an opening bracket [ without a closing.
Another line with a link-like [text](http://example.com
And a normal line.
@@ -0,0 +1,72 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import HeadingNode from '../src/components/HeadingNode/HeadingNode.vue'
import NodeRenderer from '../src/components/NodeRenderer'
import { flushAll } from './setup/flush-all'
describe('headingNode attrs passthrough', () => {
it('binds node.attrs onto the rendered heading element', async () => {
const children: any[] = []
const wrapper = mount(HeadingNode, {
props: {
node: {
type: 'heading',
level: 2,
text: '',
children,
raw: '',
attrs: {
'id': 'my-heading',
'data-test': 'heading',
},
},
},
})
const h2 = wrapper.get('h2')
expect(h2.attributes('id')).toBe('my-heading')
expect(h2.attributes('data-test')).toBe('heading')
await wrapper.setProps({
node: {
type: 'heading',
level: 2,
text: '',
children,
raw: '',
attrs: {
id: 'my-heading-2',
},
},
})
expect(wrapper.get('h2').attributes('id')).toBe('my-heading-2')
})
it('supports parseOptions.preTransformTokens injecting id on heading_open', async () => {
const wrapper = mount(NodeRenderer, {
props: {
content: '# Hello',
typewriter: false,
batchRendering: false,
parseOptions: {
preTransformTokens(tokens: any[]) {
for (const token of tokens) {
if (token?.type !== 'heading_open')
continue
if (typeof token.attrSet === 'function')
token.attrSet('id', 'hello-anchor')
else
token.attrs = [...(token.attrs ?? []), ['id', 'hello-anchor']]
}
return tokens
},
},
},
})
await flushAll()
expect(wrapper.get('h1.heading-node').attributes('id')).toBe('hello-anchor')
})
})
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest'
import {
clearHeightEstimationExperiment,
estimateCodeBlockHeight,
getHeightEstimationExperiment,
getHeightEstimationRendererController,
registerHeightEstimationRendererController,
setHeightEstimationExperiment,
} from '../src/internal/heightEstimationExperiment'
describe('height estimation experiment internals', () => {
it('stores config per custom id', () => {
setHeightEstimationExperiment('test-height-exp', {
enabled: true,
textEstimation: true,
codeBlockEstimation: false,
})
expect(getHeightEstimationExperiment('test-height-exp')).toEqual({
enabled: true,
textEstimation: true,
codeBlockEstimation: false,
})
clearHeightEstimationExperiment('test-height-exp')
expect(getHeightEstimationExperiment('test-height-exp')).toBeNull()
})
it('registers and removes renderer controllers', () => {
const controller = {
captureRestoreAnchor: () => ({ nodeIndex: 4, offsetWithinNodePx: 12 }),
restoreAnchor: () => {},
getAnchorDrift: () => 0,
getReport: () => ({
totalNodes: 1,
measuredCount: 1,
estimatedCount: 0,
averageNodeHeight: 20,
topSpacerHeight: 0,
bottomSpacerHeight: 0,
estimatedTotalHeight: 20,
width: 320,
nodes: [],
}),
}
const cleanup = registerHeightEstimationRendererController('test-height-controller', controller)
expect(getHeightEstimationRendererController('test-height-controller')).toBe(controller)
cleanup()
expect(getHeightEstimationRendererController('test-height-controller')).toBeNull()
})
it('estimates markdown code block height deterministically', () => {
const estimated = estimateCodeBlockHeight(
{
type: 'code_block',
language: 'ts',
code: 'one\ntwo\nthree',
raw: '```ts\none\ntwo\nthree\n```',
} as any,
{
rendererKind: 'markdown',
showHeader: true,
},
)
expect(estimated?.kind).toBe('code-block')
expect(estimated?.rendererKind).toBe('markdown')
expect(estimated?.contentHeight).toBeGreaterThan(0)
expect(estimated?.height).toBeGreaterThan(estimated?.contentHeight ?? 0)
})
it('caps monaco estimate by max height', () => {
const estimated = estimateCodeBlockHeight(
{
type: 'code_block',
language: 'ts',
code: new Array(120).fill('console.log("x")').join('\n'),
raw: '```ts\n...\n```',
} as any,
{
rendererKind: 'monaco',
monacoOptions: {
fontSize: 13,
lineHeight: 30,
MAX_HEIGHT: 320,
},
showHeader: true,
},
)
expect(estimated?.contentHeight).toBe(320)
expect(estimated?.height).toBe(360)
})
})
@@ -0,0 +1,22 @@
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
import { describe, expect, it } from 'vitest'
describe('html_block split after <br/>', () => {
it('parses <br/> as a standalone html_block and keeps following paragraph separate', () => {
const tags = ['thinking']
const md = getMarkdown('html-block-br-splitting', { customHtmlTags: tags })
const markdown = `<br/>
The easiest way to start the Dify server is through [Docker Compose](docker/docker-compose.yaml). Before running Dify with the following commands, make sure that [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/) are installed on your machine:`
const nodes = parseMarkdownToStructure(markdown, md, { customHtmlTags: tags }) as any[]
expect(nodes.length).toBeGreaterThanOrEqual(2)
expect(nodes[0].type).toBe('html_block')
expect(nodes[0].tag).toBe('br')
expect(String(nodes[0].raw ?? '').trim()).toBe('<br/>')
expect(nodes[1].type).toBe('paragraph')
expect(String(nodes[1].raw ?? '')).toContain('The easiest way to start the Dify server')
})
})
@@ -0,0 +1,212 @@
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
import { describe, expect, it } from 'vitest'
function expectDetailsContainer(markdown: string, name: string) {
for (const final of [false, true]) {
const md = getMarkdown(`${name}-${final}`)
const nodes = parseMarkdownToStructure(markdown, md, { final }) as any[]
expect(nodes).toHaveLength(1)
expect(nodes[0]?.type).toBe('html_block')
expect(nodes[0]?.tag).toBe('details')
expect(String(nodes[0]?.raw ?? '')).toBe(markdown)
}
}
describe('html block details regression', () => {
it('keeps details as one container node while rendering trailing blocks into html children content', () => {
const markdown = `<details style="color:gray;background-color: #f8f8f8;padding: 8px;border-radius: 4px;" open> <summary> Thinking... </summary>
好的,我需要帮用户生成首次病程记录。首先得仔细阅读提供的入院记录内容,确保所有信息都被正确提取。这里是详情
首先。。。。
</details>
`
expectDetailsContainer(markdown, 'html-block-details-regression')
const md = getMarkdown('html-block-details-regression-structure')
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
const details = nodes[0]
const summary = details?.children?.[0]
const introParagraph = details?.children?.[1]
expect(String(details?.content ?? '')).toContain('<p>首先。。。。</p>')
expect(details?.attrs).toEqual([
['style', 'color:gray;background-color: #f8f8f8;padding: 8px;border-radius: 4px;'],
['open', ''],
])
expect(details?.children).toHaveLength(3)
expect(summary?.type).toBe('html_block')
expect(summary?.tag).toBe('summary')
expect(summary?.attrs).toBeUndefined()
expect(summary?.children?.[0]?.type).toBe('paragraph')
expect(summary?.children?.[0]?.children?.[0]?.content).toContain('Thinking')
expect(introParagraph?.type).toBe('paragraph')
expect(introParagraph?.children?.[0]?.content).toContain('这里是详情')
expect(details?.children?.[2]?.type).toBe('paragraph')
expect(details?.children?.[2]?.children?.[0]?.content).toBe('首先。。。。')
})
it('keeps the blank line paragraph inside details while streaming before the closing tag arrives', () => {
const chunks = [
`<details style="color:gray;background-color: #f8f8f8;padding: 8px;border-radius: 4px;" open> <summary> Thinking... </summary>
好的,我需要帮用户生成首次病程记录。首先得仔细阅读提供的入院记录内容,确保所有信息都被正确提取。这里是详情
`,
`首先。。。。
`,
'</details>',
]
const md = getMarkdown('html-block-details-streaming')
let markdown = ''
for (let i = 0; i < chunks.length; i++) {
markdown += chunks[i]
const final = i === chunks.length - 1
const nodes = parseMarkdownToStructure(markdown, md, { final }) as any[]
const hasTrailingParagraph = markdown.includes('首先。。。。')
expect(nodes).toHaveLength(1)
expect(nodes[0]?.type).toBe('html_block')
expect(nodes[0]?.tag).toBe('details')
if (!final) {
expect(nodes[0]?.loading).toBe(true)
if (hasTrailingParagraph) {
expect(String(nodes[0]?.content ?? '')).toContain('<p>首先。。。。</p>')
expect(nodes[0]?.children?.[0]?.tag).toBe('summary')
expect(nodes[0]?.children?.[1]?.type).toBe('paragraph')
expect(nodes[0]?.children?.[2]?.type).toBe('paragraph')
}
else {
expect(String(nodes[0]?.content ?? '')).not.toContain('<p>首先。。。。</p>')
expect(nodes[0]?.children?.[0]?.tag).toBe('summary')
expect(nodes[0]?.children?.[1]?.type).toBe('paragraph')
expect(nodes[0]?.children ?? []).toHaveLength(2)
}
}
if (final) {
expect(nodes[0]?.loading).toBe(false)
expect(String(nodes[0]?.content ?? '')).toContain('<p>首先。。。。</p>')
expect(String(nodes[0]?.raw ?? '')).toBe(markdown)
}
}
})
it('keeps nested details blocks 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>`
expectDetailsContainer(markdown, 'html-block-details-nested')
const md = getMarkdown('html-block-details-nested-structure')
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
const outer = nodes[0]
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')
const trailingParagraph = outer?.children?.find((child: any) => child?.type === 'paragraph' && child?.children?.[0]?.content === 'after inner')
expect(outerSummary).toBeDefined()
expect(inner).toBeDefined()
expect(inner?.children?.[0]?.tag).toBe('summary')
expect(String(inner?.content ?? '')).toContain('<p>inner body</p>')
expect(trailingParagraph?.children?.[0]?.content).toBe('after inner')
})
it('keeps complex markdown children inside details and preserves the trailing 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 details = nodes[0]
expect(nodes).toHaveLength(1)
expect(details?.tag).toBe('details')
expect(String(details?.raw ?? '')).toBe(markdown)
expect(String(details?.content ?? '')).toContain('<ul>')
expect(String(details?.content ?? '')).toContain('<blockquote>')
expect(String(details?.content ?? '')).toContain('data-lang="js"')
expect(String(details?.content ?? '')).toContain('<p>tail</p>')
expect(details?.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 details = nodes[0]
const summary = details?.children?.[0]
const summaryParagraph = summary?.children?.[0]
expect(nodes).toHaveLength(1)
expect(details?.tag).toBe('details')
expect(summary?.tag).toBe('summary')
expect(details?.attrs).toEqual([['open', '']])
expect(summaryParagraph?.type).toBe('paragraph')
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(details?.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 details = nodes[0]
expect(nodes).toHaveLength(1)
expect(details?.tag).toBe('details')
expect(details?.attrs).toEqual([['open', '']])
expect(details?.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(details?.children?.[0]?.children?.[0]?.content).toBe('lead')
expect(details?.children?.[1]?.children?.[0]?.children?.[0]?.content).toBe('sum')
expect(details?.children?.[2]?.children?.[0]?.content).toBe('text')
expect(details?.children?.[3]?.tag).toBe('div')
expect(details?.children?.[4]?.children?.[0]?.content).toBe('para')
})
})
@@ -0,0 +1,33 @@
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
import { describe, expect, it } from 'vitest'
import { collect, textIncludes } from './utils/midstate-utils'
describe('html block regressions', () => {
const md = getMarkdown('html-block-regressions')
it('keeps fenced code inside html block content', () => {
const markdown = `<div>
\`\`\`js
console.log(1)
\`\`\`
</div>`
const nodes = parseMarkdownToStructure(markdown, md)
expect(nodes.length).toBe(1)
const htmlBlock: any = nodes[0]
expect(htmlBlock.type).toBe('html_block')
expect(htmlBlock.tag).toBe('div')
expect(htmlBlock.loading).toBe(false)
expect(htmlBlock.raw).toContain('```js')
const codeBlocks = nodes.filter((n: any) => n.type === 'code_block' || n.type === 'fence')
expect(codeBlocks.length).toBe(0)
})
it('does not treat JSON arrays like "[14]" as references', () => {
const markdown = `<question>[{"id":"1","pages":[14],"content":"本次测试的目的是什么?"},{"id":"2","pages":[5,15],"content":"如何根据MODULEID查找对应的测试信息?"}]</question>`
const nodes = parseMarkdownToStructure(markdown, md) as any[]
expect(collect(nodes, 'reference').length).toBe(0)
expect(textIncludes(nodes, '[14]')).toBe(true)
})
})
@@ -0,0 +1,41 @@
/**
* @vitest-environment jsdom
*/
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import HtmlBlockNode from '../src/components/HtmlBlockNode/HtmlBlockNode.vue'
describe('htmlBlockNode streaming DOM stability', () => {
it('keeps table element stable while loading content grows', async () => {
const nodeA = {
type: 'html_block',
tag: 'table',
raw: '<table><tr><td>1</td></tr>',
content: '<table><tr><td>1</td></tr></table>',
loading: true,
} as const
const wrapper = mount(HtmlBlockNode, {
props: {
node: nodeA as any,
},
})
await nextTick()
const first = wrapper.find('table').element
const nodeB = {
...nodeA,
raw: '<table><tr><td>1</td></tr><tr><td>2</td></tr>',
content: '<table><tr><td>1</td></tr><tr><td>2</td></tr></table>',
loading: true,
} as const
await wrapper.setProps({ node: nodeB as any })
await nextTick()
const second = wrapper.find('table').element
expect(second).toBe(first)
})
})
@@ -0,0 +1,55 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
import { describe, expect, it, vi } from 'vitest'
describe('html full document nesting', () => {
const md = getMarkdown('html-full-document-nested')
const fixture = resolve(__dirname, 'fixtures', 'html-full-document-nested.md')
it('parses a full HTML document as one complete html_block with nested levels intact', () => {
const markdown = readFileSync(fixture, 'utf8')
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('html')
expect(nodes[0].loading).toBe(false)
const html = String(nodes[0].raw ?? nodes[0].content ?? '')
expect(html).toContain('<!DOCTYPE html>')
expect(html).toContain('<html>')
expect(html).toContain('<head>')
expect(html).toContain('<body>')
expect(html).toContain('<table>')
expect(html).toContain('<tr>')
expect(html).toContain('<td>')
expect(html).toContain('<div class="chart-container">')
expect(html).toContain('<div id="exceptionChart"></div>')
expect(html).toContain('myChart.setOption(option);')
expect(html).toContain('</html>')
expect(nodes.some(n => n.type === 'code_block' || n.type === 'fence')).toBe(false)
})
it('keeps a complete html document as a single html_block in streaming mode too', () => {
const markdown = readFileSync(fixture, 'utf8')
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('html')
expect(nodes[0]?.loading).toBe(false)
})
it('keeps token hooks active while preserving single html_block shape', () => {
const markdown = readFileSync(fixture, 'utf8')
const preTransformTokens = vi.fn((tokens: any[]) => tokens)
const nodes = parseMarkdownToStructure(markdown, md, { final: true, preTransformTokens }) as any[]
expect(preTransformTokens).toHaveBeenCalledTimes(1)
expect(nodes).toHaveLength(1)
expect(nodes[0]?.type).toBe('html_block')
expect(nodes[0]?.tag).toBe('html')
expect(nodes[0]?.loading).toBe(false)
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
import { sanitizeHtmlContent } from 'stream-markdown-parser'
import { describe, expect, it } from 'vitest'
describe('sanitizeHtmlContent', () => {
it('removes dangerous attrs, unsafe urls, and blocked tags', () => {
const html = sanitizeHtmlContent('<div onclick="evil()">Hello <img src="x" onerror="evil()"><a href="javascript:alert(1)" title="ok">Link</a><script>alert(1)</script></div>')
expect(html).toContain('<div>Hello <img src="x"><a title="ok">Link</a></div>')
expect(html).not.toContain('onclick')
expect(html).not.toContain('onerror')
expect(html).not.toContain('javascript:')
expect(html).not.toContain('<script')
expect(html).not.toContain('alert(1)')
})
it('preserves whitespace around safe inline html', () => {
const html = sanitizeHtmlContent('Hello <span data-safe="1">world</span> !')
expect(html).toBe('Hello <span data-safe="1">world</span> !')
})
})
@@ -0,0 +1,45 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import ImageNode from '../src/components/ImageNode/ImageNode.vue'
describe('image node performance defaults', () => {
it('does not force lazy loading by default', () => {
const wrapper = mount(ImageNode, {
props: {
node: {
type: 'image',
src: 'https://example.com/hero.png',
alt: 'Hero',
title: 'Hero',
raw: '![Hero](https://example.com/hero.png)',
},
},
})
expect(wrapper.get('img').attributes('loading')).toBeUndefined()
expect(wrapper.get('img').attributes('fetchpriority')).toBe('high')
expect(wrapper.get('img').attributes('decoding')).toBe('sync')
expect(wrapper.get('img').classes()).not.toContain('opacity-0')
expect(wrapper.get('img').classes()).not.toContain('transition-opacity')
})
it('keeps the lower-priority async path for lazy images', () => {
const wrapper = mount(ImageNode, {
props: {
lazy: true,
node: {
type: 'image',
src: 'https://example.com/hero.png',
alt: 'Hero',
title: 'Hero',
raw: '![Hero](https://example.com/hero.png)',
},
},
})
expect(wrapper.get('img').attributes('loading')).toBe('lazy')
expect(wrapper.get('img').attributes('fetchpriority')).toBeUndefined()
expect(wrapper.get('img').attributes('decoding')).toBe('async')
expect(wrapper.get('img').classes()).toContain('is-loading')
})
})
@@ -0,0 +1,442 @@
import { describe, expect, it } from 'vitest'
import { getMarkdown, parseMarkdownToStructure } from '../packages/markdown-parser/src/index'
describe('indented code block to paragraph conversion', () => {
const md = getMarkdown()
it('should convert single-line indented text to paragraph', () => {
const markdown = ` This is just indented text`
const result = parseMarkdownToStructure(markdown, md, { final: true })
expect(result.length).toBe(1)
expect(result[0].type).toBe('paragraph')
expect(result[0].children?.[0].type).toBe('text')
expect(result[0].children?.[0].content).toBe('This is just indented text')
})
it('should keep multi-line indented code as code_block', () => {
const markdown = ` line one\n line two\n line three`
const result = parseMarkdownToStructure(markdown, md, { final: true })
expect(result.length).toBe(1)
expect(result[0].type).toBe('code_block')
})
it('should keep single-line code-like patterns as code_block', () => {
const testCases = [
// Original cases
` const x = 1`,
` function foo() {}`,
` if (true) return`,
` https://example.com`,
` ['a', 'b', 'c']`,
// Function/method calls with parentheses
` foo()`,
` obj.method()`,
` array.push(item)`,
` $.ajax()`,
` _underscore.method()`,
// Property access and chaining
` obj.prop`,
` arr[0]`,
` data.items[0].name`,
` obj?.prop`,
` this.property`,
// Comparison and logical operators
` a + b`,
` x > y`,
` count !== 0`,
` value === null`,
` flag && true`,
` result || default`,
// Assignment operators
` x = 1`,
` y += 2`,
` z *= 3`,
// JSX/TSX tags
` <Component />`,
` <div>content</div>`,
// Template strings
` \`hello ${name}\``,
// Arrow functions
` () => {}`,
` x => x * 2`,
// Decorators
` @Component`,
// Numbers with units
` 100px`,
` 1.5rem`,
` 0xFF`,
// Console methods
` console.log('test')`,
` document.getElementById('id')`,
// Command-like patterns
` git status`,
` npm install`,
` docker build`,
// Comment patterns
` // comment`,
` # comment`,
// Heredoc patterns
` <<EOF`,
]
for (const markdown of testCases) {
const result = parseMarkdownToStructure(markdown, md, { final: true })
expect(result[0].type).toBe('code_block')
}
})
it('should handle the original streamContent case correctly', () => {
const streamContent = `
<hide>uid:b238ea9cb1a44ae489247aed335a0248</hide>
<TaskPlan>
已收到您关于xxx的组织机构画像的任务请求,我将按照以下步骤执行该任务:
- 匹配并确认目标组织机构
- 构建组织机构画像管理数据
</TaskPlan>
<AnalysisSteps>
# 匹配信息和确认组织机构
## 深度思考
-
- 好的,我现在需要处理用户的输入:"生成x的织机构画像"。首先,我需要确定用户是否提到了组织机构的名称。用户提到的"x"看起来像是一个公司名称,但可能存在拼写错误,比如"织机构"应该是"组织机构"。不过,这里的关键是提取组织机构名称。根据规则,我需要检查用户输入中的组织名称,并去除所有后缀,比如"集团、公司、股份有限公司"等。但"x"这个名称中没有明显的后缀,比如"公司"或"集团"之类的词。不过,可能用户输入有误,比如"织机构"可能是"组织机构"的笔误,但这里的问题是关于提取组织名称。接下来,我需要确认"x"是否是一个有效的组织机构名称。x是印度的一家电信公司,全称可能是x,但用户输入中没有提到"Limited"或类似的后缀。因此,根据规则,如果名称中没有后缀,就保留原始名称。所以,这里应该提取"x"作为品牌名称。另外,需要检查是否有地域或业务修饰词。例如,如果名称中有"(深圳)"或"科技"之类的词,需要保留业务关键词,但这里没有。因此,直接保留"x"。还要确保没有其他可能的后缀被遗漏。例如,如果用户输入的是"x",那么需要去掉"公司"得到"x"。但当前输入中没有这样的后缀,所以直接保留。最后,检查是否符合返回"unknow"的条件。用户输入中包含了一个可能的组织名称,且去除后缀后是有效的品牌名称,因此不应返回"unknow"。
## 多维度匹配组织机构管理数据
- 调用数据接口,机构画像关联数据匹配中......
- 该机构没有关联数据匹配。
</AnalysisSteps>
&#10006; 该机构没有关联机构数据匹配,无法进行机构画像生成。
`
const result = parseMarkdownToStructure(streamContent, md, { final: true })
expect(result.length).toBe(4)
// First node: paragraph containing html_block <hide>
expect(result[0].type).toBe('paragraph')
expect(result[0].children?.[0].type).toBe('html_block')
// Second node: html_block <TaskPlan>
expect(result[1].type).toBe('html_block')
expect((result[1] as any).tag).toBe('taskplan')
// Third node: html_block <AnalysisSteps>
expect(result[2].type).toBe('html_block')
expect((result[2] as any).tag).toBe('analysissteps')
// Fourth node: paragraph (not code_block!)
expect(result[3].type).toBe('paragraph')
expect(result[3].children?.[0].type).toBe('text')
expect(result[3].children?.[0].content).toBe('&#10006; 该机构没有关联机构数据匹配,无法进行机构画像生成。')
})
describe('edge cases for looksLikeCode function', () => {
it('should convert plain indented text with special characters to paragraph', () => {
const testCases = [
` Just a sentence.`,
` Hello, world!`,
` This is text...`,
` What about this?`,
// Note: "Price: $99.99" has `:` which is treated as code-like
// Note: "50% off" has `%` which is treated as code-like
` Hello world`,
` Test message`,
` Regular text here`,
// Note: "# Not a heading..." has `#` which is treated as code (shell comment)
` - Not a list because indented`,
` * Not a list because indented`,
]
for (const markdown of testCases) {
const result = parseMarkdownToStructure(markdown, md, { final: true })
expect(result[0].type).toBe('paragraph', `Expected paragraph for: ${markdown}`)
}
})
it('should keep HTML entity patterns as paragraph (not code)', () => {
const testCases = [
` &nbsp;`,
` &amp;`,
` &copy; 2024`,
` &#10006; Error message`,
` &#10004; Success`,
` &lt;tag&gt;`,
` &quot;quoted&quot;`,
` &mdash; dash`,
` &#12345;`,
` &empty;`,
]
for (const markdown of testCases) {
const result = parseMarkdownToStructure(markdown, md, { final: true })
expect(result[0].type).toBe('paragraph', `Expected paragraph for: ${markdown}`)
}
})
it('should keep property access patterns as code_block', () => {
const testCases = [
` foo.bar`,
` obj.prop.value`,
` array[0].name`,
` data.items[index]`,
` object?.optional`,
` this.property`,
` module.exports`,
` Math.PI`,
` document.body`,
` window.location`,
]
for (const markdown of testCases) {
const result = parseMarkdownToStructure(markdown, md, { final: true })
expect(result[0].type).toBe('code_block', `Expected code_block for: ${markdown}`)
}
})
it('should keep code-like expressions as code_block', () => {
const testCases = [
` a + b`,
` x * y`,
` result || fallback`,
` flag && true`,
` count > 0`,
` value === null`,
` i++`,
` arr.length`,
` str.length`,
` !!value`,
` x ? y : z`,
` count != 0`,
` a <= b`,
]
for (const markdown of testCases) {
const result = parseMarkdownToStructure(markdown, md, { final: true })
expect(result[0].type).toBe('code_block', `Expected code_block for: ${markdown}`)
}
})
it('should keep quoted strings with trailing punctuation as code_block', () => {
const testCases = [
` "hello";`,
` 'world'`,
` \`test\``,
` "string",`,
` 'value';`,
]
for (const markdown of testCases) {
const result = parseMarkdownToStructure(markdown, md, { final: true })
expect(result[0].type).toBe('code_block', `Expected code_block for: ${markdown}`)
}
})
it('should keep JSX/TSX patterns as code_block', () => {
const testCases = [
` <Component />`,
` <div>content</div>`,
` <span className="test">`,
` <Button onClick={handler}>`,
` <input type="text" />`,
` <CustomComponent prop="value">`,
]
for (const markdown of testCases) {
const result = parseMarkdownToStructure(markdown, md, { final: true })
expect(result[0].type).toBe('code_block', `Expected code_block for: ${markdown}`)
}
})
it('should handle mixed punctuation correctly', () => {
const shouldBeParagraphs = [
` ...`,
` ---`,
` ***`,
` +++`,
` ===`,
]
for (const markdown of shouldBeParagraphs) {
const result = parseMarkdownToStructure(markdown, md, { final: true })
expect(result[0].type).toBe('paragraph', `Expected paragraph for: ${markdown}`)
}
})
it('should handle numeric values correctly', () => {
const shouldBeCode = [
` 42`,
` 3.14`,
` 0xFF`,
` 0b1010`,
` 100px`,
` 1.5rem`,
` 50%`,
` 90deg`,
` 500ms`,
]
for (const markdown of shouldBeCode) {
const result = parseMarkdownToStructure(markdown, md, { final: true })
expect(result[0].type).toBe('code_block', `Expected code_block for: ${markdown}`)
}
})
it('should handle email and URL patterns correctly', () => {
// URLs should be code
const shouldBeCode = [
` https://example.com`,
` http://test.com`,
` www.example.com`,
` //cdn.example.com`,
` ftp://files.example.com`,
]
for (const markdown of shouldBeCode) {
const result = parseMarkdownToStructure(markdown, md, { final: true })
expect(result[0].type).toBe('code_block')
}
// Email-like - @ symbol without decorator pattern should be paragraph
// user@example.com has @ but doesn't match ^@\w+$ pattern
const shouldBeCodeEmails = [
` user@example.com`,
` admin@test.org`,
]
for (const markdown of shouldBeCodeEmails) {
const result = parseMarkdownToStructure(markdown, md, { final: true })
// user@example.com has . which makes it look like property access (code)
// So we expect code_block, not paragraph
expect(result[0].type).toBe('code_block')
}
})
it('should handle edge cases with operators and symbols', () => {
const shouldBeCode = [
` =>`,
` ->`,
` ::`,
` @Component`,
` #! /bin/bash`,
` <!-- comment`,
]
for (const markdown of shouldBeCode) {
const result = parseMarkdownToStructure(markdown, md, { final: true })
expect(result[0].type).toBe('code_block')
}
})
it('should handle command-like patterns as code', () => {
const testCases = [
` git status`,
` npm install`,
` yarn add`,
` pip install`,
` cargo build`,
` docker run`,
` kubectl get pods`,
` python script.py`,
` node server.js`,
]
for (const markdown of testCases) {
const result = parseMarkdownToStructure(markdown, md, { final: true })
expect(result[0].type).toBe('code_block')
}
})
it('should handle whitespace-only content', () => {
const testCases = [
` `,
` `,
` \t`,
` \n`,
]
for (const markdown of testCases) {
const result = parseMarkdownToStructure(markdown, md, { final: true })
// Empty/whitespace content should not create a paragraph node
// or may create an empty paragraph - either is acceptable
expect(result.length).toBeGreaterThanOrEqual(0)
}
})
it('should handle Chinese text with punctuation', () => {
const shouldBeParagraphs = [
` 你好,世界!`,
` 这是一个测试。`,
` 价格:99元`,
` 完成率:50%`,
` 第1步`,
` - 不是列表`,
]
for (const markdown of shouldBeParagraphs) {
const result = parseMarkdownToStructure(markdown, md, { final: true })
expect(result[0].type).toBe('paragraph')
}
})
it('should handle bracket/brace patterns', () => {
const shouldBeCode = [
` []`,
` {}`,
` [1, 2, 3]`,
` { key: 'value' }`,
` ()`,
` (x) => x`,
` <div>`,
` </div>`,
]
for (const markdown of shouldBeCode) {
const result = parseMarkdownToStructure(markdown, md, { final: true })
expect(result[0].type).toBe('code_block')
}
})
it('should handle boolean and null-like patterns', () => {
const shouldBeCode = [
` true`,
` false`,
` null`,
` undefined`,
` NaN`,
` Infinity`,
]
for (const markdown of shouldBeCode) {
const result = parseMarkdownToStructure(markdown, md, { final: true })
expect(result[0].type).toBe('code_block')
}
})
it('should handle heredoc patterns', () => {
const shouldBeCode = [
` <<EOF`,
` << 'EOF'`,
` <<EOF`,
` <<<HTML`,
]
for (const markdown of shouldBeCode) {
const result = parseMarkdownToStructure(markdown, md, { final: true })
expect(result[0].type).toBe('code_block')
}
})
})
})
@@ -0,0 +1,7 @@
import { describe, expect, it } from 'vitest'
describe('basic', () => {
it('shared', () => {
expect(1 + 1).toBe(2)
})
})
@@ -0,0 +1,46 @@
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
import { describe, expect, it } from 'vitest'
import { links, textIncludes } from '../utils/midstate-utils'
const md = getMarkdown('midstates')
describe('autolink handling', () => {
it('bare http URL should be parsed as a link node (autolink)', () => {
const nodes = parseMarkdownToStructure('http://a', md)
const ls = links(nodes)
expect(ls.length).toBeGreaterThanOrEqual(1)
const l = ls[0] as any
expect(textIncludes(l, 'http://a') || l.href === 'http://a' || /http:\/\/.+/.test(String(l.href))).toBe(true)
// autolinks should not be considered loading
expect(!!l.loading).toBe(false)
})
it('angle-bracket autolink <http://a> should be parsed as link', () => {
const nodes = parseMarkdownToStructure('<http://a>', md)
const ls = links(nodes)
if (ls.length > 0) {
const l = ls[0] as any
expect(textIncludes(l, 'http://a') || String(l.href).includes('http://a')).toBe(true)
}
else {
// tokenizer may choose to leave raw text; accept that fallback
expect(textIncludes(nodes, '<http://a>') || textIncludes(nodes, 'http://a')).toBe(true)
}
})
it('bracketed link with title containing emoji (mid-state) should NOT be misclassified as autolink', () => {
const nodes = parseMarkdownToStructure('[x](http://a "t😁"', md)
const ls = links(nodes)
if (ls.length > 0) {
// If parser produced a link, it should be loading (mid-state) rather than finalized autolink
expect(!!ls[0].loading).toBe(true)
// title or children should include emoji or stub
const ok = textIncludes(ls[0], '😁') || textIncludes(ls[0], 't') || textIncludes(nodes, 't') || textIncludes(nodes, '😁')
expect(ok).toBe(true)
}
else {
// Accept text fallback containing the raw mid-state
expect(textIncludes(nodes, '[x](http://a "t😁"') || textIncludes(nodes, 't😁')).toBe(true)
}
})
})
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { parseCheckboxInputToken, parseCheckboxToken } from '../../packages/markdown-parser/src/parser/inline-parsers/checkbox-parser'
describe('checkbox inline parser', () => {
it('parses checkbox token from meta.checked true', () => {
const token: any = { meta: { checked: true } }
const node = parseCheckboxToken(token)
expect(node.type).toBe('checkbox')
expect(node.checked).toBe(true)
expect(node.raw).toBe('[x]')
})
it('parses checkbox input token from attrGet', () => {
const token: any = {
attrGet: (name: string) => {
if (name === 'checked')
return 'true'
return null
},
}
const node = parseCheckboxInputToken(token)
expect(node.type).toBe('checkbox_input')
expect(node.checked).toBe(true)
expect(node.raw).toBe('[x]')
})
})
@@ -0,0 +1,55 @@
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
import { describe, expect, it } from 'vitest'
const md = getMarkdown('fix-link-inline')
function collectLinks(nodes: any[]) {
const out: any[] = []
const walk = (n: any) => {
if (!n)
return
if (n.type === 'link')
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('fix-link-inline edge cases', () => {
it('handles "[*x](xx" tolerantly (link with emphasis or text fallback)', () => {
const nodes = parseMarkdownToStructure('[*x](xx', md)
const links = collectLinks(nodes as any[])
if (links.length > 0) {
const l = links[0]
const textOk = l.text === 'x' || (l.children && l.children.some((c: any) => c.type === 'emphasis'))
expect(textOk).toBe(true)
expect(!!l.loading).toBe(true)
}
else {
// fallback: ensure original raw text remains
const raw = JSON.stringify(nodes)
expect(raw.includes('[*x](xx') || raw.includes('emphasis')).toBe(true)
}
})
it('handles emoji at end of link text with unclosed URL conservatively', () => {
const nodes = parseMarkdownToStructure('[x:smile:](u', md)
const links = collectLinks(nodes as any[])
if (links.length > 0) {
const l = links[0]
expect(!!l.loading).toBe(true)
// link text should contain the emoji shortcode or contain an emoji node
const hasEmojiText = typeof l.text === 'string' && l.text.includes(':smile:')
const hasEmojiNode = Array.isArray(l.children) && l.children.some((c: any) => c.type === 'emoji' || (c.children && c.children.some((cc: any) => cc.type === 'emoji')))
expect(hasEmojiText || hasEmojiNode).toBe(true)
}
else {
const raw = JSON.stringify(nodes)
expect(raw.includes('[x:smile:](u') || raw.includes('emoji')).toBe(true)
}
})
})
@@ -0,0 +1,761 @@
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
import { describe, expect, it } from 'vitest'
const md = getMarkdown('fix-link-inline')
function collectLinks(nodes: any[]) {
const out: any[] = []
const walk = (n: any) => {
if (!n)
return
if (n.type === 'link')
out.push(n)
if (Array.isArray(n.children))
n.children.forEach(walk)
if (Array.isArray(n.items))
n.items.forEach(walk)
if (n.type === 'table') {
n.rows.forEach(walk)
n.header.cells.forEach(walk)
}
else if (n.type === 'table_row') {
n.cells.forEach(walk)
}
}
nodes.forEach(walk)
return out
}
function collectTarget(nodes: any[], targetType: string) {
const out: any[] = []
const walk = (n: any) => {
if (!n)
return
if (n.type === targetType)
out.push(n)
if (Array.isArray(n.children))
n.children.forEach(walk)
if (Array.isArray(n.items))
n.items.forEach(walk)
if (n.type === 'table') {
n.rows.forEach(walk)
n.header.cells.forEach(walk)
}
else if (n.type === 'table_row') {
n.cells.forEach(walk)
}
}
nodes.forEach(walk)
return out
}
describe('inline parser fixes (link mid-states)', () => {
it('handles loading link [x](http://a as loading link node', () => {
const nodes = parseMarkdownToStructure('[x](http://a', md)
const links = collectLinks(nodes as any[])
expect(links.length).toBeGreaterThan(0)
const l = links[0]
expect(l.text).toBe('x')
// loading should be true for unclosed parenthesis
expect(Boolean(l.loading)).toBe(true)
// href may be present or empty depending on tokenizer, but if present contains the partial href
if (l.href)
expect(String(l.href)).toContain('http')
})
it('does not treat [*x as a finalized link', () => {
const nodes = parseMarkdownToStructure('[*x', md)
const links = collectLinks(nodes as any[])
// Should not create a link from just '[*x'
expect(links.length).toBe(0)
})
it('parses finalized link [x](http://a) as non-loading link', () => {
const nodes = parseMarkdownToStructure('[x](http://a)', md)
const links = collectLinks(nodes as any[])
expect(links.length).toBeGreaterThan(0)
const l = links[0]
expect(l.loading).toBeFalsy()
expect(String(l.href)).toContain('http://a')
})
it('simple inline link', () => {
const nodes = parseMarkdownToStructure('[OpenAI](https://openai.com)', md)
const links = collectLinks(nodes as any[])
expect(links.length).toBeGreaterThan(0)
const l = links[0]
expect(l.loading).toBeFalsy()
expect(String(l.href)).toContain('https://openai.com')
expect(l.text).toBe('OpenAI')
})
it('inline link with a *title* (tooltip)', () => {
const nodes = parseMarkdownToStructure('[OpenAI](https://openai.com "OpenAI – AI research")', md)
const links = collectLinks(nodes as any[])
expect(links.length).toBeGreaterThan(0)
const l = links[0]
expect(l.loading).toBeFalsy()
expect(String(l.href)).toContain('https://openai.com')
expect(l.text).toBe('OpenAI')
// title may be stored on `title` or inside attributes depending on tokenizer
if ('title' in l)
expect(String((l as any).title)).toContain('OpenAI – AI research')
else if (l.attrs && l.attrs.title)
expect(String(l.attrs.title)).toContain('OpenAI – AI research')
})
it('**Bold** link', () => {
const nodes = parseMarkdownToStructure('**[OpenAI](https://openai.com)**', md)
const strong = (nodes[0] as any).children[0]
const link = strong.children[0]
expect(strong.type).toBe('strong')
expect(strong.raw).toBe('**OpenAI**')
expect(strong.children[0]).toBeDefined()
expect(link.type).toBe('link')
expect(link.text).toBe('OpenAI')
expect(link.loading).toBeFalsy()
expect(String(link.href)).toContain('https://openai.com')
})
it('*Italic* link', () => {
const nodes = parseMarkdownToStructure('*[OpenAI](https://openai.com)*', md)
const emphasis = (nodes[0] as any).children[0]
const link = emphasis.children[0]
expect(emphasis.type).toBe('emphasis')
expect(emphasis.raw).toBe('*OpenAI*')
expect(emphasis.children[0]).toBeDefined()
expect(link.type).toBe('link')
expect(link.text).toBe('OpenAI')
expect(link.loading).toBeFalsy()
expect(String(link.href)).toContain('https://openai.com')
})
it('***Bold + Italic*** link', () => {
const nodes = parseMarkdownToStructure('***[OpenAI](https://openai.com)***', md)
const wrapper = (nodes[0] as any).children[0]
// markdown parsers may represent triple-asterisk as strong->emphasis or emphasis->strong
expect(wrapper).toBeDefined()
// If wrapper is strong, its first child should be emphasis which contains the link
if (wrapper.type === 'strong') {
const emphasis = wrapper.children[0]
expect(emphasis).toBeDefined()
expect(emphasis.type).toBe('emphasis')
const link = emphasis.children[0]
expect(link).toBeDefined()
expect(link.type).toBe('link')
expect(link.text).toBe('OpenAI')
expect(link.loading).toBeFalsy()
expect(String(link.href)).toContain('https://openai.com')
}
else if (wrapper.type === 'emphasis') {
// If wrapper is emphasis, its first child may be strong which contains the link
const strong = wrapper.children[0]
expect(strong).toBeDefined()
expect(strong.type).toBe('strong')
const link = strong.children[0]
expect(link).toBeDefined()
expect(link.type).toBe('link')
expect(link.text).toBe('OpenAI')
expect(link.loading).toBeFalsy()
expect(String(link.href)).toContain('https://openai.com')
}
else {
// Fallback: try to find link anywhere under wrapper
const walk = (n: any): any => {
if (!n)
return null
if (n.type === 'link')
return n
if (Array.isArray(n.children)) {
for (const c of n.children) {
const found = walk(c)
if (found)
return found
}
}
return null
}
const link = walk(wrapper)
expect(link).toBeDefined()
expect(link.type).toBe('link')
expect(link.text).toBe('OpenAI')
expect(String(link.href)).toContain('https://openai.com')
}
})
it('link inside a heading', () => {
const nodes = parseMarkdownToStructure('## Learn more at [OpenAI](https://openai.com)', md)
const heading = nodes[0] as any
expect(heading).toBeDefined()
expect(heading.type).toBe('heading')
// heading children should contain the link somewhere
const link = collectLinks(heading.children || [])[0] as any
expect(link).toBeDefined()
expect(link.type).toBe('link')
expect(link.text || link.children?.[0]?.content).toBeDefined()
// text content should include OpenAI
const text = link.text || link.children?.[0]?.content || ''
expect(String(text)).toContain('OpenAI')
expect(String(link.href)).toContain('https://openai.com')
})
it('link inside a blockquote', () => {
const nodes = parseMarkdownToStructure('> Check out the docs: [OpenAI API](https://platform.openai.com/docs)', md)
const blockquote = nodes[0] as any
expect(blockquote).toBeDefined()
expect(blockquote.type).toBe('blockquote')
const link = collectLinks(blockquote.children || [])[0] as any
expect(link).toBeDefined()
expect(link.type).toBe('link')
const text = link.text || link.children?.[0]?.content || ''
expect(String(text)).toContain('OpenAI API')
expect(String(link.href)).toContain('https://platform.openai.com/docs')
})
it('link inside a list', () => {
const nodes = parseMarkdownToStructure('- Official site: [OpenAI](https://openai.com)', md)
const listNode = nodes[0] as any
expect(listNode).toBeDefined()
// lists may be represented as 'list' with items or 'list_item' children; search recursively
const link = collectLinks(listNode.children || listNode.items || [])[0] as any
expect(link).toBeDefined()
expect(link.type).toBe('link')
const text = link.text || link.children?.[0]?.content || ''
expect(String(text)).toContain('OpenAI')
expect(String(link.href)).toContain('https://openai.com')
})
it('numbered list with links', () => {
const nodes = parseMarkdownToStructure('1. First: [OpenAI](https://openai.com)', md)
const listNode = nodes[0] as any
expect(listNode).toBeDefined()
const link = collectLinks(listNode.children || listNode.items || [])[0] as any
expect(link).toBeDefined()
expect(link.type).toBe('link')
const text = link.text || link.children?.[0]?.content || ''
expect(String(text)).toContain('OpenAI')
expect(String(link.href)).toContain('https://openai.com')
})
it('link inside a table', () => {
const tableMd = `| Service | URL |
|---------|-----------------------------|
| OpenAI | [openai.com](https://openai.com) |
| ChatGPT | [chat.openai.com](https://chat.openai.com) |`
const nodes = parseMarkdownToStructure(tableMd, md)
const links = collectLinks(nodes as any[])
// expect at least two links from the two table cells
expect(links.length).toBeGreaterThanOrEqual(2)
const hrefs = links.map((l: any) => String(l.href))
expect(hrefs.some((h: string) => h.includes('https://openai.com'))).toBe(true)
expect(hrefs.some((h: string) => h.includes('https://chat.openai.com'))).toBe(true)
})
it('**Bold link inside a table**', () => {
const tableMd = `| Service | URL |
|---------|-------------------------------------|
| OpenAI | **[openai.com](https://openai.com)** |
| ChatGPT | **[chat.openai.com](https://chat.openai.com)** |`
const nodes = parseMarkdownToStructure(tableMd, md)
const strongs = collectTarget(nodes as any[], 'strong')
expect(strongs.length).toBeGreaterThanOrEqual(2)
const links = collectLinks(strongs as any[])
expect(links.length).toBeGreaterThanOrEqual(2)
const hrefs = links.map((l: any) => String(l.href))
expect(hrefs.some((h: string) => h.includes('https://openai.com'))).toBe(true)
expect(hrefs.some((h: string) => h.includes('https://chat.openai.com'))).toBe(true)
})
it('link with **escaped parentheses** in the URL', () => {
const mdText = '[Google Maps](https://www.google.com/maps/place/Statue+of+Liberty+(New+York)/)'
const nodes = parseMarkdownToStructure(mdText, md)
const links = collectLinks(nodes as any[])
expect(links.length).toBeGreaterThan(0)
const l = links[0]
expect(l.text).toBe('Google Maps')
const hrefStr = String(l.href)
expect(hrefStr).toContain('https://www.google.com/maps')
// parentheses may be preserved or percent-encoded; ensure key parts exist
expect(hrefStr).toMatch(/Statue/i)
expect(hrefStr).toMatch(/New\s*York|New\+York|New%20York/i)
})
it('autolink (bare URL)', () => {
const nodes = parseMarkdownToStructure('<https://openai.com>', md)
const links = collectLinks(nodes as any[])
expect(links.length).toBeGreaterThan(0)
const l = links[0]
// href should contain the URL
expect(String(l.href)).toContain('https://openai.com')
// text may be on `text` or children content
const text = l.text || l.children?.[0]?.content || ''
expect(String(text)).toBe('https://openai.com')
})
it('reference‑style link', () => {
const mdText = `Visit the [OpenAI website][openai].
[openai]: https://openai.com "OpenAI – AI research"`
const nodes = parseMarkdownToStructure(mdText, md)
const links = collectLinks(nodes as any[])
expect(links.length).toBeGreaterThan(0)
const l = links[0]
// href should point to OpenAI
expect(String(l.href)).toContain('https://openai.com')
// text should include the label used
const text = l.text || l.children?.[0]?.content || ''
expect(String(text)).toBe('OpenAI website')
expect(String(l.title)).toBe('OpenAI – AI research')
expect(l.loading).toBeFalsy()
})
it('reference‑style link **inside bold**', () => {
const mdText = `**See the [OpenAI docs][docs] for details.**
[docs]: https://platform.openai.com/docs`
const nodes = parseMarkdownToStructure(mdText, md)
const strong = (nodes[0] as any).children?.find((c: any) => c.type === 'strong')
expect(strong).toBeDefined()
const link = (strong.children || []).find((c: any) => c.type === 'link')
expect(link).toBeDefined()
expect(String(link.href)).toBe('https://platform.openai.com/docs')
const text = link.text || link.children?.[0]?.content || ''
expect(String(text)).toBe('OpenAI docs')
})
it('image that is also a link', () => {
const mdText = '[![OpenAI logo](https://openai.com/content/images/2022/05/openai-avatar.png)](https://openai.com)'
const nodes = parseMarkdownToStructure(mdText, md)
const links = collectLinks(nodes as any[])
expect(links.length).toBeGreaterThan(0)
const l = links[0]
expect(String(l.href)).toContain('https://openai.com')
const hasImageChild = (Array.isArray(l.children) && l.children.some((c: any) => c.type === 'image'))
|| (Array.isArray((l as any).items) && (l as any).items.some((c: any) => c.type === 'image'))
expect(hasImageChild).toBe(true)
})
it('link inside **inline code** (displayed as code, not clickable)', () => {
const mdText = '`[OpenAI](https://openai.com)`'
const nodes = parseMarkdownToStructure(mdText, md)
// ensure no links are parsed
const links = collectLinks(nodes as any[])
expect(links.length).toBe(0)
// ensure an inline_code node exists and contains the raw code
const inline = (nodes[0] as any).children?.find((c: any) => c.type === 'inline_code')
expect(inline).toBeDefined()
expect(inline.code || inline.raw).toContain('[OpenAI](https://openai.com)')
})
it('link inside a **code block** (fenced) – not rendered as a link', () => {
const mdText = '```\n[OpenAI](https://openai.com)\n```'
const nodes = parseMarkdownToStructure(mdText, md)
// No link nodes should be produced from code block content
const links = collectLinks(nodes as any[])
expect(links.length).toBe(0)
// The raw code should still appear somewhere in the node tree
const serialized = JSON.stringify(nodes)
expect(serialized).toContain('[OpenAI](https://openai.com)')
})
it('link inside **HTML** (raw HTML allowed in many Markdown flavours)', () => {
const mdText = '<p>Visit <a href="https://openai.com">OpenAI</a> for more info.'
const nodes = parseMarkdownToStructure(mdText, md)
// Some tokenizers may emit an HTML node, others may parse the anchor into link nodes.
const links = collectLinks(nodes as any[])
if (links.length > 0) {
const l = links[0]
expect(String(l.href)).toContain('https://openai.com')
const text = l.text || l.children?.[0]?.content || ''
expect(String(text)).toContain('OpenAI')
}
else {
// fallback: ensure raw HTML is present in serialized output
const serialized = JSON.stringify(nodes)
expect(serialized).toContain('<a href=\\"https://openai.com\\">OpenAI</a>')
}
})
it('link with **emoji** and bold', () => {
const mdText = '**🚀 [Launch OpenAI](https://openai.com)**'
const nodes = parseMarkdownToStructure(mdText, md)
// expect a strong node wrapping the emoji+link
const strong = (nodes[0] as any).children?.find((c: any) => c.type === 'strong')
expect(strong).toBeDefined()
expect(strong)
// link may be a direct child of strong
const link = (strong.children || []).find((c: any) => c.type === 'link')
expect(link).toBeDefined()
expect(String(link.href)).toContain('https://openai.com')
const text = link.text || link.children?.[0]?.content || ''
// ensure link text contains expected word
expect(String(text)).toContain('Launch')
})
it('link inside a **definition list** (GitHub‑flavoured Markdown)', () => {
const mdText = 'Term \n: Definition with a link to the [OpenAI site](https://openai.com).'
const nodes = parseMarkdownToStructure(mdText, md)
const links = collectLinks(nodes as any[])
expect(links.length).toBeGreaterThan(0)
const l = links[0]
expect(String(l.href)).toContain('https://openai.com')
const text = l.text || l.children?.[0]?.content || ''
expect(String(text)).toContain('OpenAI')
})
it('link with **multiple titles** (only the first title is used)', () => {
const mdText = `[OpenAI](https://openai.com "First title" "Second title")`
const nodes = parseMarkdownToStructure(mdText, md)
const links = collectLinks(nodes as any[])
expect(links.length).toBeGreaterThan(0)
const l = links[0]
expect(String(l.href)).toContain('https://openai.com')
const text = l.text || l.children?.[0]?.content || ''
expect(String(text)).toBe('OpenAI')
const titleVal = ('title' in l) ? (l as any).title : (l.attrs && l.attrs.title)
// Only assert title behavior if a non-empty title is present
if (titleVal && String(titleVal).length > 0) {
expect(String(titleVal)).toBe('First title')
expect(String(titleVal)).not.toContain('Second title')
}
})
it('link inside a **nested list**', () => {
const mdText = `- Main item
1. Sub‑item with a link: [OpenAI](https://openai.com)
2. Another sub‑item`
const nodes = parseMarkdownToStructure(mdText, md)
const links = collectLinks(nodes as any[])
expect(links.length).toBeGreaterThan(0)
const l = links.find((x: any) => String(x.href) === 'https://openai.com')
expect(l).toBeDefined()
expect(String(l.href)).toContain('https://openai.com')
const text = l.text || l.children?.[0]?.content || ''
expect(String(text)).toContain('OpenAI')
})
it('link with **non‑ASCII characters**', () => {
const mdText = `[Åbent bibliotek](https://example.com/åbent)`
const nodes = parseMarkdownToStructure(mdText, md)
const links = collectLinks(nodes as any[])
expect(links.length).toBeGreaterThan(0)
const l = links[0]
expect(String(l.href)).toContain('https://example.com')
// href may percent-encode non-ASCII; check for key path segment presence in either form
const hrefStr = String(l.href)
expect(hrefStr).toMatch(/åbent|%C3%A5bent|%E5%93%81/i)
const text = l.text || l.children?.[0]?.content || ''
expect(String(text)).toContain('Åbent')
})
it('link inside a **footnote** (CommonMark footnote syntax)', () => {
const mdText = `Here is a reference[^1].
[^1]: See the [OpenAI documentation](https://platform.openai.com/docs).`
const nodes = parseMarkdownToStructure(mdText, md)
const footnote = nodes[1] as any
expect(footnote.type).toBe('footnote')
const footnotAnchor = footnote.children[0].children.slice(-1)[0]
expect(footnotAnchor.type).toBe('footnote_anchor')
expect(footnotAnchor.id).toBe('1')
const links = collectLinks(footnote.children)
const l = links[0]
expect(links.length).toBeGreaterThan(0)
expect(l.loading).toBe(false)
expect(l.href).toBe('https://platform.openai.com/docs')
expect(String(l.text || l.children?.[0]?.content || '')).toContain('OpenAI documentation')
})
it('link with **HTML entity** in the link text', () => {
const nodes = parseMarkdownToStructure('[OpenAI &amp; ChatGPT](https://openai.com)', md)
const links = collectLinks(nodes as any[])
expect(links.length).toBeGreaterThan(0)
const l = links[0]
expect(l.loading).toBeFalsy()
expect(String(l.href)).toContain('https://openai.com')
const text = l.text || l.children?.[0]?.content || ''
expect(String(text)).toContain('OpenAI')
expect(String(text)).toContain('ChatGPT')
// Allow either decoded '&' or the HTML entity '&amp;'
expect(String(text)).toMatch(/&amp;|&/)
})
it('link with **line break** inside the link text (using `<br>`)', () => {
const nodes = parseMarkdownToStructure('[OpenAI<br>Platform](https://platform.openai.com)', md)
const links = collectLinks(nodes as any[])
expect(links.length).toBeGreaterThan(0)
const l = links[0]
expect(l.loading).toBeFalsy()
expect(String(l.href)).toContain('https://platform.openai.com')
const text = l.text || l.children?.[0]?.content || ''
// Ensure both parts of the link text are present
expect(String(text)).toContain('OpenAI')
expect(String(text)).toContain('Platform')
// Allow the parser to represent the <br> as a literal tag, a newline, or other whitespace
expect(String(text)).toMatch(/OpenAI[\s\S]*Platform/)
})
it('**Bold image link** (image wrapped in bold and also a link)', () => {
const mdText = '**[![OpenAI logo](https://openai.com/content/images/2022/05/openai-avatar.png)](https://openai.com)**'
const nodes = parseMarkdownToStructure(mdText, md)
// Look for strong nodes that may wrap the link
const strongs = collectTarget(nodes as any[], 'strong')
expect(strongs.length).toBeGreaterThanOrEqual(1)
const links = collectLinks(strongs as any[])
// There should be at least one link inside the strong wrapper
expect(links.length).toBeGreaterThan(0)
const l = links[0]
expect(String(l.href)).toContain('https://openai.com')
const hasImageChild = (Array.isArray(l.children) && l.children.some((c: any) => c.type === 'image'))
|| (Array.isArray((l as any).items) && (l as any).items.some((c: any) => c.type === 'image'))
expect(hasImageChild).toBe(true)
})
it('link inside a **table header**', () => {
const tableMd = `| **Service** | **URL** |
|-------------|----------|
| OpenAI | [openai.com](https://openai.com) |`
const nodes = parseMarkdownToStructure(tableMd, md)
const links = collectLinks(nodes as any[])
// link should be found in the table body
expect(links.length).toBeGreaterThan(0)
const hrefs = links.map((l: any) => String(l.href))
expect(hrefs.some((h: string) => h.includes('https://openai.com'))).toBe(true)
// ensure header cells exist and include the bold header text
const tables = collectTarget(nodes as any[], 'table')
expect(tables.length).toBeGreaterThan(0)
const header = (tables[0] as any).header
expect(header).toBeDefined()
const headerTexts = header.cells.map((c: any) => {
if (c.children)
return c.children.map((ch: any) => ch.content || ch.raw || '').join('')
return String(c.raw || '')
}).join(' | ')
expect(String(headerTexts)).toContain('Service')
expect(String(headerTexts)).toContain('URL')
})
it('parses 3 adjacent links on the same line as 3 link nodes', () => {
const markdown = '[citation](http://url1) [citation](http://url1) [citation](http://url1)'
const nodes = parseMarkdownToStructure(markdown, md)
const links = collectLinks(nodes as any[])
expect(links).toHaveLength(3)
expect(links.map((l: any) => l.href)).toEqual(['http://url1', 'http://url1', 'http://url1'])
})
it('does not leak partial href like "(htt" into text nodes in streaming mid-state', () => {
const markdown = '[citation](htt'
const nodes = parseMarkdownToStructure(markdown, md, { final: false })
const links = collectLinks(nodes as any[])
expect(links.length).toBeGreaterThan(0)
expect(Boolean(links[0].loading)).toBe(true)
const texts = collectTarget(nodes as any[], 'text').map((t: any) => String(t.content ?? ''))
expect(texts.join('')).not.toContain('(htt')
})
it('never renders "(http" as text during char-by-char streaming for multiple links', () => {
const full = '[citation](http://url1) [citation](http://url1) [citation](http://url1)'
for (let i = 1; i <= full.length; i++) {
const chunk = full.slice(0, i)
const nodes = parseMarkdownToStructure(chunk, md, { final: false })
const texts = collectTarget(nodes as any[], 'text').map((t: any) => String(t.content ?? '')).join('')
// If this fails, include the chunk to make the failing prefix obvious.
expect(texts, `prefix(${i}): ${chunk}`).not.toContain('(http')
}
})
it('keeps streaming markdown images as image loading nodes instead of flipping to links', () => {
const full = '![Picture](https://imzbf.github.io/md-editor-rt/imgs/mark_emoji.gif)'
const earlyNodes = parseMarkdownToStructure(full.slice(0, 2), md, { final: false })
expect(collectTarget(earlyNodes as any[], 'image')).toHaveLength(1)
expect(collectLinks(earlyNodes as any[])).toHaveLength(0)
for (const chunk of [full.slice(0, 20), full.slice(0, 60)]) {
const nodes = parseMarkdownToStructure(chunk, md, { final: false })
const images = collectTarget(nodes as any[], 'image')
const links = collectLinks(nodes as any[])
expect(images.length, chunk).toBe(1)
expect(images[0].loading, chunk).toBe(true)
expect(images[0].alt, chunk).toBe('Picture')
expect(links, chunk).toHaveLength(0)
}
const finalNodes = parseMarkdownToStructure(full, md, { final: true })
const finalImages = collectTarget(finalNodes as any[], 'image')
const finalLinks = collectLinks(finalNodes as any[])
expect(finalImages).toHaveLength(1)
expect(finalImages[0].loading).toBe(false)
expect(finalImages[0].src).toBe('https://imzbf.github.io/md-editor-rt/imgs/mark_emoji.gif')
expect(finalImages[0].alt).toBe('Picture')
expect(finalLinks).toHaveLength(0)
})
it('does not turn escaped exclamation plus link into an image mid-state', () => {
const nodes = parseMarkdownToStructure('\\![Picture](https://imzbf.github.io/md-editor-rt/imgs/mark_emoji.gif', md, { final: false })
const images = collectTarget(nodes as any[], 'image')
const links = collectLinks(nodes as any[])
const texts = collectTarget(nodes as any[], 'text').map((t: any) => String(t.content ?? '')).join('')
expect(images).toHaveLength(0)
expect(links).toHaveLength(1)
expect(texts).toContain('!')
})
it('keeps nested image links as loading links when the outer link has just started', () => {
const veryEarlyMarkdown = '[![P'
const veryEarlyNodes = parseMarkdownToStructure(veryEarlyMarkdown, md, { final: false })
const veryEarlyImages = collectTarget(veryEarlyNodes as any[], 'image')
const veryEarlyLinks = collectLinks(veryEarlyNodes as any[])
const veryEarlyTexts = collectTarget(veryEarlyNodes as any[], 'text')
expect(veryEarlyImages).toHaveLength(1)
expect(veryEarlyLinks).toHaveLength(1)
expect(veryEarlyLinks[0].loading).toBe(true)
expect(veryEarlyLinks[0].href).toBe('')
expect(veryEarlyLinks[0].children[0].type).toBe('image')
expect(veryEarlyTexts).toHaveLength(0)
const hrefEarlyMarkdown = '[![Picture](https://img'
const hrefEarlyNodes = parseMarkdownToStructure(hrefEarlyMarkdown, md, { final: false })
const hrefEarlyImages = collectTarget(hrefEarlyNodes as any[], 'image')
const hrefEarlyLinks = collectLinks(hrefEarlyNodes as any[])
const hrefEarlyTexts = collectTarget(hrefEarlyNodes as any[], 'text')
expect(hrefEarlyImages).toHaveLength(1)
expect(hrefEarlyLinks).toHaveLength(1)
expect(hrefEarlyLinks[0].loading).toBe(true)
expect(hrefEarlyLinks[0].href).toBe('')
expect(hrefEarlyLinks[0].children[0].type).toBe('image')
expect(hrefEarlyTexts).toHaveLength(0)
const earlyMarkdown = '[![Picture](https://img)'
const earlyNodes = parseMarkdownToStructure(earlyMarkdown, md, { final: false })
const earlyImages = collectTarget(earlyNodes as any[], 'image')
const earlyLinks = collectLinks(earlyNodes as any[])
const earlyTexts = collectTarget(earlyNodes as any[], 'text')
expect(earlyImages).toHaveLength(1)
expect(earlyLinks).toHaveLength(1)
expect(earlyLinks[0].loading).toBe(true)
expect(earlyLinks[0].href).toBe('')
expect(earlyLinks[0].children).toHaveLength(1)
expect(earlyLinks[0].children[0].type).toBe('image')
expect(earlyTexts).toHaveLength(0)
const markdown = '[![Picture](https://img)]('
const nodes = parseMarkdownToStructure(markdown, md, { final: false })
const images = collectTarget(nodes as any[], 'image')
const links = collectLinks(nodes as any[])
const texts = collectTarget(nodes as any[], 'text')
expect(images).toHaveLength(1)
expect(links).toHaveLength(1)
expect(links[0].loading).toBe(true)
expect(links[0].href).toBe('')
expect(links[0].children).toHaveLength(1)
expect(links[0].children[0].type).toBe('image')
expect(texts).toHaveLength(0)
})
it('preserves text before a nested image link while the outer link is incomplete', () => {
const markdown = 'prefix [![Picture](https://img)'
const nodes = parseMarkdownToStructure(markdown, md, { final: false })
const images = collectTarget(nodes as any[], 'image')
const links = collectLinks(nodes as any[])
const texts = collectTarget(nodes as any[], 'text').map((t: any) => String(t.content ?? ''))
expect(images).toHaveLength(1)
expect(links).toHaveLength(1)
expect(links[0].loading).toBe(true)
expect(links[0].children).toHaveLength(1)
expect(links[0].children[0].type).toBe('image')
expect(texts.join('')).toBe('prefix ')
})
it('preserves text before very early nested image links', () => {
const markdown = 'prefix [![P'
const nodes = parseMarkdownToStructure(markdown, md, { final: false })
const images = collectTarget(nodes as any[], 'image')
const links = collectLinks(nodes as any[])
const texts = collectTarget(nodes as any[], 'text').map((t: any) => String(t.content ?? ''))
expect(images).toHaveLength(1)
expect(links).toHaveLength(1)
expect(links[0].loading).toBe(true)
expect(links[0].children[0].type).toBe('image')
expect(texts.join('')).toBe('prefix ')
})
it('does not turn escaped outer brackets before images into nested image links', () => {
for (const markdown of [
'\\[![P',
'\\[![Picture](https://img',
'\\[![Picture](https://img)',
'\\[![Picture](https://img)](',
'\\[![Picture](https://img)](https://outer',
]) {
const nodes = parseMarkdownToStructure(markdown, md, { final: false })
const images = collectTarget(nodes as any[], 'image')
const links = collectLinks(nodes as any[])
const texts = collectTarget(nodes as any[], 'text').map((t: any) => String(t.content ?? '')).join('')
expect(images.length, markdown).toBe(1)
expect(links.length, markdown).toBe(0)
expect(texts, markdown).toContain('[')
}
})
it('preserves prefix text for escaped outer brackets before images', () => {
const markdown = 'prefix \\[![Picture](https://img'
const nodes = parseMarkdownToStructure(markdown, md, { final: false })
const images = collectTarget(nodes as any[], 'image')
const links = collectLinks(nodes as any[])
const texts = collectTarget(nodes as any[], 'text').map((t: any) => String(t.content ?? '')).join('')
expect(images).toHaveLength(1)
expect(links).toHaveLength(0)
expect(texts).toBe('prefix [')
})
it('still allows double escaped outer bracket sequences to form real nested image links', () => {
const markdown = '\\\\[![Picture](https://img'
const nodes = parseMarkdownToStructure(markdown, md, { final: false })
const images = collectTarget(nodes as any[], 'image')
const links = collectLinks(nodes as any[])
const texts = collectTarget(nodes as any[], 'text').map((t: any) => String(t.content ?? '')).join('')
expect(images).toHaveLength(1)
expect(links).toHaveLength(1)
expect(links[0].loading).toBe(true)
expect(links[0].children[0].type).toBe('image')
expect(texts).toBe('\\')
})
it('keeps nested image links as loading links while the outer href streams', () => {
const markdown = '[![Picture](https://img)](https://outer'
const nodes = parseMarkdownToStructure(markdown, md, { final: false })
const images = collectTarget(nodes as any[], 'image')
const links = collectLinks(nodes as any[])
const texts = collectTarget(nodes as any[], 'text')
expect(images).toHaveLength(1)
expect(links).toHaveLength(1)
expect(links[0].loading).toBe(true)
expect(links[0].href).toBe('https://outer')
expect(links[0].children).toHaveLength(1)
expect(links[0].children[0].type).toBe('image')
expect(texts).toHaveLength(0)
})
})
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'
import { parseImageToken } from '../../packages/markdown-parser/src/parser/inline-parsers/image-parser'
// Minimal token-like shapes used by parser (only include fields used by parser)
function makeChildImageToken() {
return {
type: 'image',
tag: 'img',
attrs: [
['src', 'https://example.com/vue.png'],
['alt', ''],
['title', 'Vue Logo'],
],
content: 'Vue Logo',
children: [
{
type: 'text',
content: 'Vue Logo',
},
],
} as unknown as any
}
function makeOuterInlineToken() {
return {
type: 'inline',
tag: '',
attrs: null,
content: '![Vue Logo](https://example.com/vue.png "Vue Logo")',
children: [makeChildImageToken()],
} as unknown as any
}
describe('image-parser', () => {
it('falls back to child attrs or children content when attrs.alt is empty', () => {
const outer = makeOuterInlineToken()
const node = parseImageToken(outer as any)
expect(node.src).toBe('https://example.com/vue.png')
expect(node.title).toBe('Vue Logo')
// alt should be taken from token.content/child when attrs.alt is empty
expect(node.alt).toBe('Vue Logo')
})
it('uses child text when attrs.alt and parent content are empty', () => {
const child = makeChildImageToken()
// make the child have empty alt attr and parent content empty
child.attrs = [
['src', 'https://example.com/vue.png'],
['alt', ''],
['title', 'Vue Logo'],
]
child.content = ''
child.children = [
{
type: 'text',
content: 'Child Alt Text',
},
]
const outer = {
type: 'inline',
tag: '',
attrs: null,
content: '',
children: [child],
} as unknown as any
const node = parseImageToken(outer as any)
expect(node.src).toBe('https://example.com/vue.png')
expect(node.title).toBe('Vue Logo')
// alt should be taken from the inner child's text content
expect(node.alt).toBe('Child Alt Text')
})
})
@@ -0,0 +1,37 @@
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
import { describe, expect, it } from 'vitest'
function collectLinks(nodes: any[]) {
const out: any[] = []
const walk = (n: any) => {
if (!n)
return
if (n.type === 'link')
out.push(n)
if (Array.isArray(n.children))
n.children.forEach(walk)
if (Array.isArray(n.items))
n.items.forEach(walk)
if (n.type === 'table') {
n.rows.forEach(walk)
n.header.cells.forEach(walk)
}
else if (n.type === 'table_row') {
n.cells.forEach(walk)
}
}
nodes.forEach(walk)
return out
}
describe('parseMarkdownToStructure - multiple links same line', () => {
it('parses 3 adjacent links as 3 link nodes', () => {
const md = getMarkdown('multiple-links-same-line')
const markdown = '[citation](http://url1) [citation](http://url1) [citation](http://url1)'
const nodes = parseMarkdownToStructure(markdown, md)
const links = collectLinks(nodes)
expect(links).toHaveLength(3)
expect(links.map(l => l.href)).toEqual(['http://url1', 'http://url1', 'http://url1'])
})
})
@@ -0,0 +1,263 @@
import { describe, expect, it } from 'vitest'
import { getMarkdown, parseMarkdownToStructure } from '../packages/markdown-parser/src/index'
/**
* Integration tests for multiple plugins working together.
*
* These tests verify that:
* 1. fixHtmlInline and fixIndentedCodeBlock work correctly together
* 2. fixHtmlInline and fixLinkTokens work correctly together
* 3. All plugins maintain correct behavior in streaming scenarios
*/
describe('integration tests for multiple plugins', () => {
const md = getMarkdown()
describe('fixHtmlInline + fixIndentedCodeBlock', () => {
it('should handle custom tags with indented content correctly', () => {
// This test combines custom tag handling with indented code block conversion
const markdown = `
<custom>
This is indented text that should not be a code block
</custom>
This is also indented text
`
const result = parseMarkdownToStructure(markdown, md, { final: true })
// First node should be html_block with custom tag
expect(result[0].type).toBe('html_block')
expect((result[0] as any).tag).toBe('custom')
// Second node should be paragraph (indented text converted to paragraph)
expect(result[1].type).toBe('paragraph')
})
it('should handle HTML entities in indented lines within custom tags', () => {
const markdown = `
<thinking>
Processing...
&#10006; Error occurred
</thinking>
&#10004; Success message
`
const result = parseMarkdownToStructure(markdown, md, { final: true })
// First node: html_block with thinking tag
expect(result[0].type).toBe('html_block')
expect((result[0] as any).tag).toBe('thinking')
// Second node: paragraph (indented HTML entity converted to paragraph)
expect(result[1].type).toBe('paragraph')
expect(result[1].children?.[0].type).toBe('text')
expect(result[1].children?.[0].content).toContain('&#10004;')
})
it('should handle mixed content with code and paragraphs', () => {
const markdown = `
<step>
const x = 1
</step>
const y = 2
This is just text
`
const result = parseMarkdownToStructure(markdown, md, { final: true })
// First node: html_block with step tag
expect(result[0].type).toBe('html_block')
expect((result[0] as any).tag).toBe('step')
// Check we have the expected nodes
expect(result.length).toBeGreaterThanOrEqual(2)
})
})
describe('fixHtmlInline + fixLinkTokens', () => {
it('should handle links within custom tags', () => {
const markdown = `
<note>
Check out https://example.com for more info
</note>
See also [link](https://example.com/page)
`
const result = parseMarkdownToStructure(markdown, md, { final: true })
// First node: html_block with note tag
expect(result[0].type).toBe('html_block')
expect((result[0] as any).tag).toBe('note')
// Second node should be paragraph with link
expect(result[1].type).toBe('paragraph')
})
it('should handle autolinks and custom tags together', () => {
const markdown = `
<info>
Visit <https://example.com> for details
</info>
Visit <https://another.com>
`
const result = parseMarkdownToStructure(markdown, md, { final: true })
// First node: html_block with info tag
expect(result[0].type).toBe('html_block')
// Second node - might be code_block because it starts with "Visit <"
// which could be interpreted as a code pattern
expect(result[1]).toBeDefined()
})
})
describe('streaming scenarios with multiple plugins', () => {
it('should handle incomplete custom tag followed by indented content', () => {
// Simulate streaming: custom tag without closing
const markdown = `<note>This is streaming content`
const result = parseMarkdownToStructure(markdown, md, { final: false })
// Should create a node (exact structure depends on plugin handling)
expect(result.length).toBeGreaterThan(0)
})
it('should handle complete custom tag with trailing indented content', () => {
const markdown = `
<note>
Important note
</note>
This is indented text with code keyword
const code = here
`
const result = parseMarkdownToStructure(markdown, md, { final: true })
// First node: html_block with note tag
expect(result[0].type).toBe('html_block')
// Check that we have the expected number of nodes
// The "code" keyword might make it a code_block
expect(result.length).toBeGreaterThanOrEqual(2)
})
it('should handle nested custom tags with HTML entities', () => {
const markdown = `
<outer>
<inner>
&#10006; Error
</inner>
</outer>
&#10004; Success
`
const result = parseMarkdownToStructure(markdown, md, { final: true })
// First node: html_block with outer tag
expect(result[0].type).toBe('html_block')
expect((result[0] as any).tag).toBe('outer')
// Second node: paragraph (indented HTML entity)
expect(result[1].type).toBe('paragraph')
expect(result[1].children?.[0].content).toContain('&#10004;')
})
})
describe('complex real-world scenarios', () => {
it('should handle AI streaming output with multiple custom tags', () => {
const markdown = `
<hide>uid:b238ea9cb1a44ae489247aed335a0248</hide>
<TaskPlan>
已收到您关于xxx的组织机构画像的任务请求,我将按照以下步骤执行该任务:
- 匹配并确认目标组织机构
- 构建组织机构画像管理数据
</TaskPlan>
<AnalysisSteps>
# 匹配信息和确认组织机构
## 深度思考
- 好的,我现在需要处理用户的输入:"生成x的织机构画像"。
</AnalysisSteps>
&#10006; 该机构没有关联机构数据匹配,无法进行机构画像生成。
`
const result = parseMarkdownToStructure(markdown, md, { final: true })
// Verify structure
expect(result.length).toBeGreaterThanOrEqual(4)
// First node: paragraph containing html_block <hide>
expect(result[0].type).toBe('paragraph')
expect(result[0].children?.[0].type).toBe('html_block')
// Second node: html_block <TaskPlan>
expect(result[1].type).toBe('html_block')
expect((result[1] as any).tag).toBe('taskplan')
// Third node: html_block <AnalysisSteps>
expect(result[2].type).toBe('html_block')
expect((result[2] as any).tag).toBe('analysissteps')
// Fourth node: paragraph (not code_block!)
expect(result[3].type).toBe('paragraph')
expect(result[3].children?.[0].type).toBe('text')
expect(result[3].children?.[0].content).toContain('&#10006;')
})
it('should handle markdown content mixed with custom tags', () => {
const markdown = `
# Heading
<custom>
This is custom content
</custom>
## Subheading
Indented content here
Regular paragraph with **bold** text.
`
const result = parseMarkdownToStructure(markdown, md, { final: true })
// Verify heading is parsed correctly
expect(result[0].type).toBe('heading')
// Custom tag should be html_block
const customNode = result.find((n: any) => n.tag === 'custom')
expect(customNode).toBeDefined()
expect(customNode.type).toBe('html_block')
// Regular paragraph should exist
expect(result.some((n: any) => n.type === 'paragraph')).toBe(true)
})
it('should handle code fences alongside custom tags', () => {
const markdown = `
<thinking>
Processing data...
</thinking>
\`\`\`javascript
const x = 1;
console.log(x);
\`\`\`
This is indented text
const y = 2;
`
const result = parseMarkdownToStructure(markdown, md, { final: true })
// Custom tag
expect(result[0].type).toBe('html_block')
expect((result[0] as any).tag).toBe('thinking')
// The code fence and indented content should be processed
// We just verify we have multiple nodes
expect(result.length).toBeGreaterThan(1)
})
})
})
@@ -0,0 +1,40 @@
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
import { describe, expect, it } from 'vitest'
const markdown = `- 代入数据:$c=0.75\\times10^3\\ \\text{J/(kg·℃)}$,$m=1.1\\ \\text{kg}$,$\\Delta t=40℃$
- 计算得:$Q_1=0.75\\times10^3\\ \\text{J/(kg·℃)}\\times1.1\\ \\text{kg}\\times40℃=3.3\\times 10^{4}\\ \\text{J}$
- 第(1)问:学生遗漏了比热容的数量级$10^3$,将$0.75\\times10^3\\ \\text{J/(kg·℃)}$错写为$0.75\\ \\text{J/(kg·℃)}$,导致计算出的$Q_1$错误。`
function collectMathNodes(node: any, mathNodes: any[] = []) {
if (!node)
return mathNodes
if (node.type === 'math_inline' || node.type === 'math_block')
mathNodes.push(node)
if (Array.isArray(node.children))
node.children.forEach(child => collectMathNodes(child, mathNodes))
if (Array.isArray(node.items))
node.items.forEach(child => collectMathNodes(child, mathNodes))
return mathNodes
}
describe('issue #378 parse regression', () => {
it('keeps the affected unit formulas as math nodes', () => {
const md = getMarkdown('issue-378-parse')
const nodes = parseMarkdownToStructure(markdown, md, { final: true }) as any[]
const mathNodes = nodes.flatMap(node => collectMathNodes(node))
expect(mathNodes.map(node => node.raw)).toEqual([
'$c=0.75\\times10^3\\ \\text{J/(kg·℃)}$',
'$m=1.1\\ \\text{kg}$',
'$\\Delta t=40℃$',
'$Q_1=0.75\\times10^3\\ \\text{J/(kg·℃)}\\times1.1\\ \\text{kg}\\times40℃=3.3\\times 10^{4}\\ \\text{J}$',
'$10^3$',
'$0.75\\times10^3\\ \\text{J/(kg·℃)}$',
'$0.75\\ \\text{J/(kg·℃)}$',
'$Q_1$',
])
})
})
@@ -0,0 +1,80 @@
/**
* @vitest-environment jsdom
*/
import { mount } from '@vue/test-utils'
import { beforeAll, describe, expect, it, vi } from 'vitest'
import { flushAll } from './setup/flush-all'
let MarkdownRender: any
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>结束`
async function mountMarkdown(markdown: string, extraProps: Record<string, unknown> = {}) {
const wrapper = mount(MarkdownRender, {
props: {
content: markdown,
...extraProps,
},
})
await flushAll()
await flushAll()
return wrapper
}
describe('issue #386 renderer regressions', () => {
beforeAll(async () => {
vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000)
MarkdownRender = (await import('../src/components/NodeRenderer')).default
})
it('renders bracketed superscript syntax from markdown content', async () => {
const wrapper = await mountMarkdown('测试^[1]^')
const sup = wrapper.find('sup.superscript-node')
expect(sup.exists()).toBe(true)
expect(sup.text()).toBe('[1]')
expect(wrapper.text()).not.toContain('^[1]^')
})
it('renders superscript syntax immediately after inline math', async () => {
const wrapper = await mountMarkdown('$x$^[1]^')
const sup = wrapper.find('sup.superscript-node')
expect(sup.exists()).toBe(true)
expect(sup.text()).toBe('[1]')
expect(wrapper.text()).not.toContain('^[1]^')
})
it('preserves brackets inside standard inline html tags', async () => {
const wrapper = await mountMarkdown('测试<sup>[3]</sup>。')
const sup = wrapper.find('.html-inline-node sup')
expect(sup.exists()).toBe(true)
expect(sup.text()).toBe('[3]')
})
it('renders the real multiline issue-386 input in streaming mode without leaking raw superscript syntax', async () => {
const wrapper = await mountMarkdown(REAL_WORLD_MULTILINE_INPUT, { final: false })
const superscripts = wrapper.findAll('sup.superscript-node')
const inlineHtmlSup = wrapper.findAll('.html-inline-node sup')
expect(superscripts).toHaveLength(7)
expect(superscripts.map(node => node.text())).toEqual(['[1]', '[1]', '[1]', '[1]', '[1]', '[1]', '[1]'])
expect(inlineHtmlSup.map(node => node.text())).toEqual(['[3]', '[1]', '[12]', '[3]', '[3]', '[12]'])
expect(wrapper.text()).not.toContain('^[1]^')
})
})
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest'
import { getMarkdown, parseMarkdownToStructure } from '../packages/markdown-parser/src'
import { collect, links, textIncludes } from './utils/midstate-utils'
const md = getMarkdown('issue-402')
describe('issue #402 filename-like linkify regression', () => {
it('keeps filename cells as plain text inside markdown tables', () => {
const input = `当前可用的文件如下:
| 文件名 | 文件ID |
|--------|--------|
| 制度04-XX银行不良信贷资产转让实施细则.docx | f0f026a0a3b84b68af457786ac1271c4GbdC4y7vIs |
| 制度03-XX银行呆账核销实施细则.md | 7a092ea2aabd4460917e1a8347927548jocBz0soj8 |
| 制度02-XX银行呆账核销管理办法.md | d2b786c5ab9a48b5a4755b783a2c8668b2Tp9pCBmH |
| 制度01-财政部文件-《金融企业呆账核销管理办法(2017年版)》.md | 511e9efd721940868eb873c1beb67e43wMl7m22Kb5 |
`
const nodes = parseMarkdownToStructure(input, md, { final: true })
const tables = collect(nodes, 'table') as any[]
expect(tables).toHaveLength(1)
expect(links(nodes)).toHaveLength(0)
expect(textIncludes(nodes, '制度03-XX银行呆账核销实施细则.md')).toBe(true)
expect(textIncludes(nodes, '制度02-XX银行呆账核销管理办法.md')).toBe(true)
})
it('still preserves normal bare-domain autolinks', () => {
const nodes = parseMarkdownToStructure('访问 example.com 获取更多信息。', md, { final: true })
const linkNodes = links(nodes)
expect(linkNodes).toHaveLength(1)
expect(linkNodes[0].href).toBe('http://example.com')
expect(textIncludes(linkNodes[0], 'example.com')).toBe(true)
})
it('preserves bare domains whose TLDs overlap with file extensions', () => {
const nodes = parseMarkdownToStructure('访问 example.ai、example.md 和 OpenAI.ai 获取更多信息。', md, { final: true })
const linkNodes = links(nodes)
expect(linkNodes).toHaveLength(3)
expect(linkNodes.map(link => link.href)).toEqual([
'http://example.ai',
'http://example.md',
'http://OpenAI.ai',
])
})
it('preserves adjacent cjk bare domains when they are intended as links', () => {
const nodes = parseMarkdownToStructure('请访问example.ai,获取更多信息。', md, { final: true })
const linkNodes = links(nodes)
expect(linkNodes).toHaveLength(1)
expect(linkNodes[0].href).toBe('http://example.ai')
expect(textIncludes(linkNodes[0], 'example.ai')).toBe(true)
})
it('preserves punycoded bare domains whose tlds overlap with file extensions', () => {
const nodes = parseMarkdownToStructure('也可以访问 xn--fsqu00a.ai 查看说明。', md, { final: true })
const linkNodes = links(nodes)
expect(linkNodes).toHaveLength(1)
expect(linkNodes[0].href).toBe('http://xn--fsqu00a.ai')
expect(textIncludes(linkNodes[0], 'xn--fsqu00a.ai')).toBe(true)
})
it('keeps standalone uppercase filenames as text', () => {
const nodes = parseMarkdownToStructure('README.md', md, { final: true })
expect(links(nodes)).toHaveLength(0)
expect(textIncludes(nodes, 'README.md')).toBe(true)
})
it('keeps file-like paths as text instead of preserving linkify output', () => {
const nodes = parseMarkdownToStructure('请查看 docs/README.md 和 src/index.ts。', md, { final: true })
expect(links(nodes)).toHaveLength(0)
expect(textIncludes(nodes, 'docs/README.md')).toBe(true)
expect(textIncludes(nodes, 'src/index.ts')).toBe(true)
})
})
@@ -0,0 +1,49 @@
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
import { describe, expect, it } from 'vitest'
function nodeToText(node: any): string {
if (!node)
return ''
if (typeof node.raw === 'string' && node.raw)
return node.raw
if (typeof node.content === 'string' && node.content)
return node.content
if (Array.isArray(node.children)) {
return node.children
.map((c: any) => (typeof c?.content === 'string' ? c.content : (typeof c?.raw === 'string' ? c.raw : '')))
.join('')
}
return ''
}
describe('parseMarkdownToStructure - JSON object text', () => {
it('should not emit unexpected leading nodes', () => {
const md = getMarkdown('json-test')
// Regression: a JSON line ending with `[` (e.g. `"question": [`) used to
// be mis-detected as a bracket-math block starter and would inject leading
// top-level `text` tokens (duplicating `"question": ` as paragraphs).
const markdown = `
{
"summarize": "demo",
"question": [
"q1",
"q2"
]
}
`
// Mirror the playground setup: `final` defaults to false and custom tags are enabled.
const nodes = parseMarkdownToStructure(markdown, md, { customHtmlTags: ['thinking'] })
expect(nodes.length).toBeGreaterThan(0)
// Leading blank lines should not create nodes.
const first = nodes[0]
expect(first?.type).not.toBe('thematic_break')
const firstText = nodeToText(first)
expect(firstText.trim().length).toBeGreaterThan(0)
expect(firstText.trimStart().startsWith('{')).toBe(true)
const tokens = md.parse(markdown, { __markstreamFinal: false }) as any[]
expect(tokens[0]?.type).not.toBe('text')
})
})
@@ -0,0 +1,90 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import NodeRenderer from '../src/components/NodeRenderer'
import { flushAll } from './setup/flush-all'
describe('linkNode attrs passthrough', () => {
it('binds node.attrs onto the rendered anchor element', async () => {
const wrapper = mount(NodeRenderer, {
props: {
typewriter: false,
batchRendering: false,
nodes: [
{
type: 'paragraph',
raw: '',
children: [
{
type: 'link',
href: 'https://example.com',
title: null,
text: 'Example',
raw: '[Example](https://example.com)',
attrs: [
['target', '_self'],
['rel', 'nofollow'],
['data-track', 'cta'],
],
children: [
{
type: 'text',
content: 'Example',
raw: 'Example',
},
],
},
],
},
],
},
})
await flushAll()
const a = wrapper.get('a.link-node')
expect(a.attributes('target')).toBe('_self')
expect(a.attributes('rel')).toBe('nofollow')
expect(a.attributes('data-track')).toBe('cta')
})
it('sanitizes dangerous node.attrs before binding', async () => {
const wrapper = mount(NodeRenderer, {
props: {
typewriter: false,
batchRendering: false,
nodes: [
{
type: 'paragraph',
raw: '',
children: [
{
type: 'link',
href: 'https://example.com',
title: null,
text: 'Example',
raw: '[Example](https://example.com)',
attrs: [
['onclick', 'alert(1)'],
['href', 'javascript:alert(1)'],
['data-safe', '1'],
],
children: [
{
type: 'text',
content: 'Example',
raw: 'Example',
},
],
},
],
},
],
},
})
await flushAll()
const a = wrapper.get('a.link-node')
expect(a.attributes('onclick')).toBeUndefined()
expect(a.attributes('href')).toBe('https://example.com')
expect(a.attributes('data-safe')).toBe('1')
})
})
@@ -0,0 +1,112 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import NodeRenderer from '../src/components/NodeRenderer'
import { flushAll } from './setup/flush-all'
describe('link loading state', () => {
it('renders a subtle loading hint instead of an interactive anchor', async () => {
const wrapper = mount(NodeRenderer, {
props: {
typewriter: false,
batchRendering: false,
nodes: [
{
type: 'paragraph',
raw: '',
children: [
{
type: 'link',
href: 'https://example.com',
title: null,
text: 'Example',
raw: '[Example](https://example.com',
loading: true,
children: [
{
type: 'text',
content: 'Example',
raw: 'Example',
},
],
},
],
},
],
},
})
await flushAll()
expect(wrapper.find('a.link-node').exists()).toBe(false)
const loading = wrapper.get('.link-loading')
expect(loading.text()).toContain('Example')
expect(loading.find('.link-loading-indicator').exists()).toBe(true)
})
it('replays appended-text fade while a loading link label grows', async () => {
const wrapper = mount(NodeRenderer, {
props: {
batchRendering: false,
nodes: [
{
type: 'paragraph',
raw: '',
children: [
{
type: 'link',
href: 'https://example.com',
title: null,
text: 'Exam',
raw: '[Exam](https://example.com',
loading: true,
children: [
{
type: 'text',
content: 'Exam',
raw: 'Exam',
},
],
},
],
},
],
},
})
await flushAll()
await wrapper.setProps({
nodes: [
{
type: 'paragraph',
raw: '',
children: [
{
type: 'link',
href: 'https://example.com',
title: null,
text: 'Example',
raw: '[Example](https://example.com',
loading: true,
children: [
{
type: 'text',
content: 'Example',
raw: 'Example',
},
],
},
],
},
],
})
await flushAll()
const loading = wrapper.get('.link-loading')
const delta = loading.get('.text-node-stream-delta')
expect(delta.text()).toBe('ple')
expect(loading.text()).toContain('Example')
})
})
@@ -0,0 +1,411 @@
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)
})
})
@@ -0,0 +1,28 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import NodeRenderer from '../src/components/NodeRenderer'
import { flushAll } from './setup/flush-all'
describe('list item streaming fade', () => {
it('replays appended-text fade inside list items', async () => {
const wrapper = mount(NodeRenderer, {
props: {
batchRendering: false,
content: '- Hello',
},
})
await flushAll()
await wrapper.setProps({
content: '- HelloWorld',
})
await flushAll()
const listItem = wrapper.get('li.list-item')
const delta = wrapper.get('li.list-item .text-node-stream-delta')
expect(delta.text()).toBe('World')
expect(listItem.text()).toContain('HelloWorld')
})
})
@@ -0,0 +1,96 @@
import { getMarkdown } from 'stream-markdown-parser'
import { describe, expect, it } from 'vitest'
describe('列表中的数学公式解析测试', () => {
it('应该正确解析列表项中的数学公式块', () => {
const md = getMarkdown('test')
const testContent = `
1. 这是第一个列表项
\\[
E = mc^2
\\]
2. 这是第二个列表项,包含付费转化率公式:
\\[
\\text{付费转化率} = \\left( \\frac{\\text{付费用户数}}{\\text{月活用户数}} \\right) \\times 100\\%
\\]
3. 使用双美元符号的数学公式:
$$
\\frac{363}{15,\\!135} \\times 100\\% = 2.398\\%
$$
`
const tokens = md.parse(testContent, {})
// 查找数学块 tokens
const mathBlocks = tokens.filter(token => token.type === 'math_block')
console.log('所有 tokens:')
tokens.forEach((token, i) => {
console.log(
` ${i}: ${token.type} ${token.tag || ''} - ${(
token.content || ''
).slice(0, 50)}...`,
)
})
console.log('\\n数学块详情:')
mathBlocks.forEach((block, i) => {
console.log(` 数学块 ${i + 1}:`)
console.log(` 内容: ${block.content}`)
console.log(` 标记: ${block.markup}`)
})
// 验证是否找到了数学块
expect(mathBlocks.length).toBe(3)
expect(mathBlocks[0].content).toContain('E = mc^2')
expect(mathBlocks[1].content).toContain('付费转化率')
expect(mathBlocks[2].content).toContain('363')
})
it('应该正确解析嵌套列表中的数学公式', () => {
const md = getMarkdown('test')
const testContent = `
1. 第一级列表
- 第二级列表项
\\[
x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}
\\]
- 另一个第二级列表项
$$
\\int_0^1 x^2 dx = \\frac{1}{3}
$$
2. 另一个第一级列表项
1. 有序嵌套列表
\\[
\\sum_{i=1}^n i = \\frac{n(n+1)}{2}
\\]
`
const tokens = md.parse(testContent, {})
// 查找数学块 tokens
const mathBlocks = tokens.filter(token => token.type === 'math_block')
console.log('\\n=== 嵌套列表数学公式测试 ===')
console.log('数学块数量:', mathBlocks.length)
mathBlocks.forEach((block, i) => {
console.log(`数学块 ${i + 1}:`)
console.log(` 内容: ${block.content}`)
console.log(` 标记: ${block.markup}`)
console.log('---')
})
// 验证是否找到了数学块
expect(mathBlocks.length).toBe(3)
expect(mathBlocks[0].content).toContain(
'x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}',
)
expect(mathBlocks[1].content).toContain('\\int_0^1 x^2 dx')
expect(mathBlocks[2].content).toContain('\\sum_{i=1}^n i')
})
})
@@ -0,0 +1,31 @@
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
import { describe, expect, it } from 'vitest'
const md = getMarkdown()
describe('ordered list start parsing', () => {
it('captures start on second ordered list separated by code block', () => {
const markdown = [
'1. First, let\'s set up the project:',
'',
'```',
'code',
'```',
'',
'2. Create the main Electron file:',
].join('\n')
const nodes = parseMarkdownToStructure(markdown, md)
// find list nodes
const lists = nodes.filter(n => n.type === 'list')
expect(lists.length).toBe(2)
const first = lists[0] as any
const second = lists[1] as any
expect(first.ordered).toBe(true)
expect(first.start === undefined || first.start === 1).toBeTruthy()
expect(second.ordered).toBe(true)
// second should have start=2
expect(second.start).toBe(2)
})
})
@@ -0,0 +1,131 @@
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import MarkdownCodeBlockNode from '../src/components/MarkdownCodeBlockNode/MarkdownCodeBlockNode.vue'
import { hideTooltip } from '../src/composables/useSingletonTooltip'
function makeNode() {
return {
type: 'code_block' as const,
language: 'plaintext',
code: 'console.log(1)',
raw: '```txt\nconsole.log(1)\n```',
}
}
function wait(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}
async function waitTooltipShow() {
await wait(100)
await nextTick()
}
describe('markdownCodeBlockNode tooltip toggles', () => {
afterEach(async () => {
hideTooltip(true)
await nextTick()
})
it('renders action buttons with default tooltip settings', async () => {
const wrapper = mount(MarkdownCodeBlockNode, {
props: {
loading: false,
node: makeNode(),
},
})
const firstActionBtn = wrapper.find('button.code-action-btn')
expect(firstActionBtn.exists()).toBe(true)
wrapper.unmount()
})
it('does not show tooltip when showTooltips is false', async () => {
const wrapper = mount(MarkdownCodeBlockNode, {
props: {
loading: false,
node: makeNode(),
showTooltips: false,
},
})
const firstActionBtn = wrapper.find('button.code-action-btn')
expect(firstActionBtn.exists()).toBe(true)
await firstActionBtn.trigger('focus', { clientX: 32, clientY: 24 })
await waitTooltipShow()
expect(firstActionBtn.attributes('aria-describedby')).toBeUndefined()
wrapper.unmount()
})
it('disables tooltips when showTooltips is false', async () => {
const wrapper = mount(MarkdownCodeBlockNode, {
props: {
loading: false,
node: makeNode(),
showTooltips: false,
},
})
const firstActionBtn = wrapper.find('button.code-action-btn')
await firstActionBtn.trigger('focus', { clientX: 28, clientY: 28 })
await waitTooltipShow()
expect(firstActionBtn.attributes('aria-describedby')).toBeUndefined()
wrapper.unmount()
})
it('normalizes streamed language labels in the header', async () => {
const jsonWrapper = mount(MarkdownCodeBlockNode, {
props: {
loading: false,
node: {
...makeNode(),
language: 'json:package.json',
},
},
})
expect(jsonWrapper.text()).toContain('JSON')
expect(jsonWrapper.text()).not.toContain('Json:package.json')
jsonWrapper.unmount()
const shellWrapper = mount(MarkdownCodeBlockNode, {
props: {
loading: false,
node: {
...makeNode(),
language: 'shellscript',
},
},
})
expect(shellWrapper.text()).toContain('Shell')
expect(shellWrapper.text()).not.toContain('Shellscript')
shellWrapper.unmount()
})
it('keeps fallback and rendered code in the same content layer', async () => {
const wrapper = mount(MarkdownCodeBlockNode, {
props: {
loading: true,
stream: false,
node: makeNode(),
},
})
await nextTick()
const content = wrapper.get('.code-block-content')
const renderer = wrapper.get('.code-block-render')
const fallback = wrapper.get('.code-fallback-plain')
expect(renderer.element.parentElement).toBe(content.element)
expect(fallback.element.parentElement).toBe(content.element)
expect(fallback.find('pre.shiki-fallback').exists()).toBe(true)
wrapper.unmount()
})
})
@@ -0,0 +1,151 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import MarkdownCodeBlockNode from '../src/components/MarkdownCodeBlockNode/MarkdownCodeBlockNode.vue'
function makeNode(code = 'console.log(1)') {
return {
type: 'code_block' as const,
language: 'plaintext',
code,
raw: `\`\`\`txt\n${code}\n\`\`\``,
}
}
function makeScrollable(element: HTMLElement) {
Object.defineProperty(element, 'scrollHeight', {
configurable: true,
value: 1000,
})
Object.defineProperty(element, 'clientHeight', {
configurable: true,
value: 100,
})
element.scrollTop = 0
}
async function flushCodeWatchers() {
await nextTick()
await Promise.resolve()
await nextTick()
}
describe('markdownCodeBlockNode props and features', () => {
it('should support all header control props', () => {
const expectedProps = [
'showHeader',
'showCopyButton',
'showExpandButton',
'showPreviewButton',
'showFontSizeButtons',
'showTooltips',
'enableFontSizeControl',
]
// These props should now be supported on MarkdownCodeBlockNode
expectedProps.forEach((prop) => {
expect(prop).toBeTruthy()
})
})
it('should support language detection for preview', () => {
const htmlLanguages = ['html', 'svg']
const isPreviewable = (lang: string, isShowPreview: boolean) => {
return isShowPreview && htmlLanguages.includes(lang.toLowerCase())
}
expect(isPreviewable('html', true)).toBe(true)
expect(isPreviewable('svg', true)).toBe(true)
expect(isPreviewable('javascript', true)).toBe(false)
expect(isPreviewable('html', false)).toBe(false)
})
it('should support font size controls', () => {
const codeFontMin = 10
const codeFontMax = 36
const codeFontStep = 1
const defaultSize = 14
const increaseFont = (current: number) => Math.min(codeFontMax, current + codeFontStep)
const decreaseFont = (current: number) => Math.max(codeFontMin, current - codeFontStep)
const resetFont = () => defaultSize
expect(increaseFont(14)).toBe(15)
expect(increaseFont(36)).toBe(36)
expect(decreaseFont(14)).toBe(13)
expect(decreaseFont(10)).toBe(10)
expect(resetFont()).toBe(14)
})
it('should emit proper events', () => {
const expectedEvents = ['previewCode', 'copy']
expectedEvents.forEach((event) => {
expect(event).toBeTruthy()
})
})
it('should support named slots', () => {
const expectedSlots = ['header-left', 'header-right']
expectedSlots.forEach((slot) => {
expect(slot).toBeTruthy()
})
})
it('should support auto-scroll to bottom behavior', () => {
// Test the isAtBottom helper function logic with 50px threshold
const isAtBottom = (scrollHeight: number, scrollTop: number, clientHeight: number, threshold = 50): boolean => {
return scrollHeight - scrollTop - clientHeight <= threshold
}
// Test when at bottom
expect(isAtBottom(1000, 900, 100, 50)).toBe(true) // exactly at bottom
expect(isAtBottom(1000, 860, 100, 50)).toBe(true) // within threshold (40px from bottom)
// Test when not at bottom
expect(isAtBottom(1000, 840, 100, 50)).toBe(false) // beyond threshold (60px from bottom)
expect(isAtBottom(1000, 500, 100, 50)).toBe(false) // in middle
expect(isAtBottom(1000, 0, 100, 50)).toBe(false) // at top
})
it('should not auto-scroll when autoScrollOnUpdate is false', async () => {
const wrapper = mount(MarkdownCodeBlockNode, {
props: {
loading: false,
node: makeNode(),
autoScrollOnUpdate: false,
},
})
const content = wrapper.get('.code-block-content').element as HTMLElement
makeScrollable(content)
await wrapper.setProps({
node: makeNode('line\n'.repeat(80)),
})
await flushCodeWatchers()
expect(content.scrollTop).toBe(0)
wrapper.unmount()
})
it('should start with auto-scroll disabled when autoScrollInitial is false', async () => {
const wrapper = mount(MarkdownCodeBlockNode, {
props: {
loading: false,
node: makeNode(),
autoScrollInitial: false,
},
})
const content = wrapper.get('.code-block-content').element as HTMLElement
makeScrollable(content)
await wrapper.setProps({
node: makeNode('line\n'.repeat(80)),
})
await flushCodeWatchers()
expect(content.scrollTop).toBe(0)
wrapper.unmount()
})
})
@@ -0,0 +1,880 @@
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('"![x](u" is a mid-state image; either image or plain text is acceptable until closed', () => {
const nodes = parseMarkdownToStructure('![x](u', md)
// 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, '![x](u')).toBe(true)
})
it('"![x](url)" becomes an image node', () => {
const nodes = parseMarkdownToStructure('![x](http://img)', 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('"![:smile:](u" mid-state: allow image node or plain text', () => {
const nodes = parseMarkdownToStructure('![:smile:](u', md)
const images = collect(nodes, 'image')
expect(images.length >= 0).toBe(true)
expect(images.length > 0 || textIncludes(nodes, '![:smile:](u')).toBe(true)
})
it('"![:smile:](http://img)" finalized image; alt may include emoji or raw text', () => {
const nodes = parseMarkdownToStructure('![:smile:](http://img)', 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)
}
})
})
})
})
@@ -0,0 +1,123 @@
import { describe, expect, it } from 'vitest'
import { resolveParsedNodes } from '../packages/markstream-angular/src/components/shared/node-helpers'
import {
clearGlobalCustomComponents,
getCustomNodeComponents,
removeCustomComponents,
setCustomComponents,
} from '../packages/markstream-angular/src/customComponents'
import {
disableKatex,
enableKatex,
isKatexEnabled,
setKatexLoader,
} from '../packages/markstream-angular/src/optional/katex'
import {
disableMermaid,
enableMermaid,
isMermaidEnabled,
setMermaidLoader,
} from '../packages/markstream-angular/src/optional/mermaid'
import { buildKaTeXCDNWorkerSource } from '../packages/markstream-angular/src/workers/katexCdnWorker'
import { buildMermaidCDNWorkerSource } from '../packages/markstream-angular/src/workers/mermaidCdnWorker'
describe('markstream-angular api parity helpers', () => {
it('merges global and scoped custom components like react/vue2', () => {
const GlobalCode = class {}
const ScopedThinking = class {}
clearGlobalCustomComponents()
setCustomComponents({ code_block: GlobalCode as any })
setCustomComponents('scope-a', { thinking: ScopedThinking as any })
expect(getCustomNodeComponents()).toMatchObject({
code_block: GlobalCode,
})
expect(getCustomNodeComponents('scope-a')).toMatchObject({
code_block: GlobalCode,
thinking: ScopedThinking,
})
removeCustomComponents('scope-a')
clearGlobalCustomComponents()
expect(getCustomNodeComponents('scope-a')).toEqual({})
})
it('supports toggling katex and mermaid loaders', () => {
const katexLoader = () => ({ renderToString: () => '<span />' })
const mermaidLoader = () => ({
render: async () => ({ svg: '<svg />' }),
initialize() {},
})
disableKatex()
disableMermaid()
expect(isKatexEnabled()).toBe(false)
expect(isMermaidEnabled()).toBe(false)
enableKatex(katexLoader)
enableMermaid(mermaidLoader)
expect(isKatexEnabled()).toBe(true)
expect(isMermaidEnabled()).toBe(true)
setKatexLoader(null)
setMermaidLoader(null)
expect(isKatexEnabled()).toBe(false)
expect(isMermaidEnabled()).toBe(false)
enableKatex()
enableMermaid()
})
it('passes customHtmlTags through top-level streaming parsing', () => {
const nodes = resolveParsedNodes({
content: '<thinking>我们需要给出一个全面的回答',
final: false,
customHtmlTags: ['thinking'],
})
expect(nodes).toHaveLength(1)
expect((nodes[0] as any)?.type).toBe('thinking')
expect((nodes[0] as any)?.loading).toBe(true)
expect(String((nodes[0] as any)?.content || '')).toContain('全面的回答')
})
it('keeps streaming thinking content grouped across mixed markdown prefixes', () => {
const source = `<thinking>
我们需要给出一个全面的回答,可能包括不同的实现方法及其分析。
$x = a$
- 递归
- 动态规划
`
for (const size of [16, 36, 72, source.length]) {
const nodes = resolveParsedNodes({
content: source.slice(0, size),
final: false,
customHtmlTags: ['thinking'],
})
expect(nodes).toHaveLength(1)
expect((nodes[0] as any)?.type).toBe('thinking')
expect(String((nodes[0] as any)?.content || '')).not.toContain('<thinking>')
}
})
it('exposes worker helper source builders for katex and mermaid parity', () => {
const katexSource = buildKaTeXCDNWorkerSource({
katexUrl: 'https://cdn.example.com/katex.mjs',
mode: 'module',
})
const mermaidSource = buildMermaidCDNWorkerSource({
mermaidUrl: 'https://cdn.example.com/mermaid.mjs',
mode: 'module',
})
expect(katexSource).toContain('renderToString')
expect(katexSource).toContain('postMessage')
expect(mermaidSource).toContain('findPrefix')
expect(mermaidSource).toContain('canParse')
})
})
@@ -0,0 +1,247 @@
import { describe, expect, it, vi } from 'vitest'
import { enhanceRenderedHtml } from '../packages/markstream-angular/src/enhanceRenderedHtml'
const {
monacoCleanup,
canParseOffthread,
findPrefixOffthread,
mermaidState,
} = vi.hoisted(() => ({
monacoCleanup: vi.fn(),
canParseOffthread: vi.fn(async () => true),
findPrefixOffthread: vi.fn(async () => null),
mermaidState: { failOnBToC: false },
}))
vi.mock('../packages/markstream-angular/src/optional/katex', () => ({
isKatexEnabled: vi.fn(() => true),
getKatex: vi.fn(async () => ({
renderToString(source: string, options?: { displayMode?: boolean }) {
return options?.displayMode
? `<span class="katex-display">${source}</span>`
: `<span class="katex">${source}</span>`
},
})),
}))
vi.mock('../packages/markstream-angular/src/optional/mermaid', () => ({
isMermaidEnabled: vi.fn(() => true),
getMermaid: vi.fn(async () => ({
render: vi.fn(async (_id: string, source: string) => {
if (mermaidState.failOnBToC && source.includes('B-->C'))
throw new Error('Incomplete mermaid graph')
return {
svg: `<svg data-mermaid="1">${source}</svg>`,
}
}),
})),
}))
vi.mock('../packages/markstream-angular/src/workers/mermaidWorkerClient', () => ({
canParseOffthread,
findPrefixOffthread,
}))
vi.mock('../packages/markstream-angular/src/optional/d2', () => ({
getD2: vi.fn(async () => class MockD2 {
async compile(code: string) {
return { diagram: { code }, renderOptions: {} }
}
async render(diagram: { code: string }) {
return {
svg: `<svg data-d2="1"><text>${diagram.code}</text></svg>`,
}
}
}),
}))
vi.mock('../packages/markstream-angular/src/optional/infographic', () => ({
getInfographic: vi.fn(async () => class MockInfographic {
container: HTMLElement
constructor(options: { container: HTMLElement }) {
this.container = options.container
}
render(source: string) {
this.container.innerHTML = `<svg data-infographic="1"><text>${source}</text></svg>`
}
destroy() {
this.container.dataset.infographicDestroyed = '1'
}
}),
}))
vi.mock('../packages/markstream-angular/src/optional/monaco', () => ({
getUseMonaco: vi.fn(async () => ({
useMonaco: vi.fn(() => ({
async createEditor(container: HTMLElement, code: string, language: string) {
container.innerHTML = `<div data-monaco="1" data-language="${language}">${code}</div>`
},
async setTheme() {},
cleanupEditor: monacoCleanup,
})),
})),
}))
describe('markstream-angular enhanceRenderedHtml', () => {
it('hydrates math, mermaid, monaco, infographic, and d2 blocks in place', async () => {
monacoCleanup.mockReset()
canParseOffthread.mockReset()
findPrefixOffthread.mockReset()
canParseOffthread.mockImplementation(async () => true)
findPrefixOffthread.mockImplementation(async () => null)
mermaidState.failOnBToC = false
const onCopy = vi.fn()
const root = document.createElement('div')
root.innerHTML = `
<div class="markstream-angular markdown-renderer">
<span class="markstream-nested-math" data-display="inline"><span class="markstream-nested-math__source">E = mc^2</span><span class="markstream-nested-math__render"></span></span>
<div class="markstream-nested-math-block"><pre class="markstream-nested-math-block__source"><code>\\int_0^1 x^2 dx</code></pre><div class="markstream-nested-math-block__render"></div></div>
<pre data-markstream-code-block="1" data-markstream-language="mermaid"><code class="language-mermaid">graph TD; A-->B;</code></pre>
<pre data-markstream-code-block="1" data-markstream-language="ts"><code class="language-ts">const value = 1</code></pre>
<pre data-markstream-code-block="1" data-markstream-language="ts" data-markstream-diff="1" data-markstream-original="Y29uc3QgdmFsdWUgPSAx" data-markstream-updated="Y29uc3QgdmFsdWU6IG51bWJlciA9IDE="><code class="language-ts">-const value = 1
+const value: number = 1</code></pre>
<pre data-markstream-code-block="1" data-markstream-language="infographic"><code class="language-infographic">infographic list-row-simple-horizontal-arrow</code></pre>
<pre data-markstream-code-block="1" data-markstream-language="d2"><code class="language-d2">a -> b</code></pre>
</div>
`
const shell = root.querySelector('.markstream-angular') as HTMLElement
const handle = await enhanceRenderedHtml(shell, { final: true, isDark: true, onCopy, showTooltips: true })
expect(shell.innerHTML).toContain('class="katex"')
expect(shell.innerHTML).toContain('class="katex-display"')
expect(shell.innerHTML).toContain('data-mermaid="1"')
expect(shell.innerHTML).toContain('markstream-angular-mermaid')
expect(shell.innerHTML).toContain('data-monaco="1"')
expect(shell.innerHTML).toContain('data-markstream-monaco-diff="1"')
expect(shell.innerHTML).toContain('data-infographic="1"')
expect(shell.innerHTML).toContain('data-d2="1"')
expect(shell.innerHTML).toContain('markstream-angular-enhanced-block__action')
const copyButton = shell.querySelector<HTMLButtonElement>('.markstream-angular-enhanced-block__action')
copyButton?.click()
expect(onCopy).toHaveBeenCalled()
handle.dispose()
expect(monacoCleanup).toHaveBeenCalledTimes(2)
})
it('skips heavy code/diagram upgrades while content is still streaming', async () => {
monacoCleanup.mockReset()
canParseOffthread.mockReset()
findPrefixOffthread.mockReset()
canParseOffthread.mockImplementation(async () => true)
findPrefixOffthread.mockImplementation(async () => null)
mermaidState.failOnBToC = false
const root = document.createElement('div')
root.innerHTML = `
<div class="markstream-angular markdown-renderer">
<span class="markstream-nested-math" data-display="inline"><span class="markstream-nested-math__source">a+b</span><span class="markstream-nested-math__render"></span></span>
<pre data-markstream-code-block="1" data-markstream-language="ts"><code class="language-ts">const value = 1</code></pre>
<pre data-markstream-code-block="1" data-markstream-language="d2"><code class="language-d2">a -> b</code></pre>
</div>
`
const shell = root.querySelector('.markstream-angular') as HTMLElement
await enhanceRenderedHtml(shell, { final: false })
expect(shell.innerHTML).toContain('class="katex"')
expect(shell.innerHTML).toContain('language-ts')
expect(shell.innerHTML).not.toContain('data-monaco="1"')
expect(shell.innerHTML).not.toContain('data-d2="1"')
expect(monacoCleanup).not.toHaveBeenCalled()
})
it('does not re-render KaTeX from already-rendered output', async () => {
canParseOffthread.mockReset()
findPrefixOffthread.mockReset()
canParseOffthread.mockImplementation(async () => true)
findPrefixOffthread.mockImplementation(async () => null)
mermaidState.failOnBToC = false
const root = document.createElement('div')
root.innerHTML = `
<div class="markstream-angular markdown-renderer">
<span class="markstream-nested-math" data-display="inline"><span class="markstream-nested-math__source">x = a</span><span class="markstream-nested-math__render"></span></span>
<div class="markstream-nested-math-block"><pre class="markstream-nested-math-block__source"><code>f(x) = x^2</code></pre><div class="markstream-nested-math-block__render"></div></div>
</div>
`
const shell = root.querySelector('.markstream-angular') as HTMLElement
await enhanceRenderedHtml(shell, { final: false })
const firstPass = shell.innerHTML
await enhanceRenderedHtml(shell, { final: false })
expect(shell.innerHTML).toBe(firstPass)
expect(shell.innerHTML).toContain('class="katex"')
expect(shell.innerHTML).toContain('class="katex-display"')
})
it('re-renders KaTeX when the source text changes during streaming', async () => {
canParseOffthread.mockReset()
findPrefixOffthread.mockReset()
canParseOffthread.mockImplementation(async () => true)
findPrefixOffthread.mockImplementation(async () => null)
mermaidState.failOnBToC = false
const root = document.createElement('div')
root.innerHTML = `
<div class="markstream-angular markdown-renderer">
<span class="markstream-nested-math" data-display="inline"><span class="markstream-nested-math__source">x = a</span><span class="markstream-nested-math__render"></span></span>
<div class="markstream-nested-math-block"><pre class="markstream-nested-math-block__source"><code>f(x) = x^2</code></pre><div class="markstream-nested-math-block__render"></div></div>
</div>
`
const shell = root.querySelector('.markstream-angular') as HTMLElement
await enhanceRenderedHtml(shell, { final: false })
expect(shell.innerHTML).toContain('x = a')
expect(shell.innerHTML).toContain('f(x) = x^2')
const inlineSource = shell.querySelector('.markstream-nested-math__source')
const blockSource = shell.querySelector('.markstream-nested-math-block__source code')
inlineSource!.textContent = 'x = a + b'
blockSource!.textContent = 'f(x) = x^3'
await enhanceRenderedHtml(shell, { final: false })
expect(shell.innerHTML).toContain('x = a + b')
expect(shell.innerHTML).toContain('f(x) = x^3')
expect(shell.querySelector('.markstream-nested-math')?.getAttribute('data-markstream-katex-source')).toBe('x = a + b')
expect(shell.querySelector('.markstream-nested-math-block')?.getAttribute('data-markstream-katex-source')).toBe('f(x) = x^3')
})
it('renders a mermaid prefix preview while streaming when the full diagram is not yet valid', async () => {
canParseOffthread.mockReset()
findPrefixOffthread.mockReset()
canParseOffthread.mockImplementation(async (source: string) => !source.includes('B-->C'))
findPrefixOffthread.mockImplementation(async () => 'graph LR\nA-->B\n')
mermaidState.failOnBToC = true
const root = document.createElement('div')
root.innerHTML = `
<div class="markstream-angular markdown-renderer">
<pre data-markstream-code-block="1" data-markstream-language="mermaid"><code class="language-mermaid">graph LR
A-->B
B-->C
</code></pre>
</div>
`
const shell = root.querySelector('.markstream-angular') as HTMLElement
await enhanceRenderedHtml(shell, { final: false })
expect(canParseOffthread).toHaveBeenCalled()
expect(findPrefixOffthread).toHaveBeenCalled()
expect(shell.innerHTML).toContain('data-mermaid="1"')
expect(shell.innerHTML).toContain('markstream-angular-mermaid')
const previewHost = shell.querySelector('.markstream-angular-mermaid')
expect(previewHost?.innerHTML).toContain('A--&gt;B')
expect(previewHost?.innerHTML).not.toContain('B--&gt;C')
mermaidState.failOnBToC = false
})
})
@@ -0,0 +1,266 @@
import { describe, expect, it } from 'vitest'
import {
renderMarkdownNodeToHtml,
renderMarkdownToHtml,
renderNestedMarkdownToHtml,
} from '../packages/markstream-angular/src/renderMarkdownHtml'
import { sanitizeHtmlContent as sanitizeHtmlFragment } from '../packages/markstream-angular/src/sanitizeHtmlContent'
describe('markstream-angular html renderer', () => {
it('renders markdown content into stable html for baseline blocks', () => {
const html = renderMarkdownToHtml({
content: `# Angular\n\n- one\n- two\n\n\`\`\`ts\nconst value = 1\n\`\`\``,
final: true,
})
expect(html).toContain('<h1>Angular</h1>')
expect(html).toContain('<ul><li><p>one</p></li><li><p>two</p></li></ul>')
expect(html).toContain('data-markstream-code-block="1"')
expect(html).toContain('data-markstream-language="ts"')
expect(html).toContain('class="language-ts"')
expect(html).toContain('const value = 1')
})
it('preserves diff metadata on code fences for advanced enhancers', () => {
const html = renderMarkdownToHtml({
content: `\`\`\`diff ts
-const value = 1
+const value: number = 1
\`\`\``,
final: true,
})
expect(html).toContain('data-markstream-diff="1"')
expect(html).toContain('data-markstream-original="')
expect(html).toContain('data-markstream-updated="')
expect(html).toContain('data-markstream-language="ts"')
})
it('renders nested custom nodes with merged classes', () => {
const html = renderNestedMarkdownToHtml(
{
nodes: [
{
type: 'thinking',
tag: 'thinking',
raw: '',
attrs: [['class', 'preset']],
children: [
{
type: 'paragraph',
raw: '',
children: [
{ type: 'text', raw: '', content: 'Inner ' },
{
type: 'strong',
raw: '',
children: [{ type: 'text', raw: '', content: 'bold' }],
},
],
},
],
},
],
},
{
customHtmlTags: ['thinking'],
customNodeClass: 'thinking-node__nested',
},
)
expect(html).toContain('<strong>bold</strong>')
expect(html).toContain('data-markstream-custom-tag="thinking"')
expect(html).toContain('class="markstream-nested-custom markstream-nested-custom--thinking thinking-node__nested preset"')
})
it('escapes incomplete html nodes instead of trusting partial markup', () => {
const html = renderMarkdownNodeToHtml(
{
type: 'html_inline',
raw: '',
content: '<span>partial',
loading: true,
autoClosed: false,
} as any,
)
expect(html).toBe('&lt;span&gt;partial')
})
it('preserves safe html structures like details and summary', () => {
const html = renderMarkdownToHtml({
content: `<details>\n<summary>More</summary>\n<p>Body</p>\n</details>`,
final: true,
})
expect(html).toContain('<details>')
expect(html).toContain('<summary>')
expect(html).toContain('More')
expect(html).toContain('<p>Body</p>')
expect(html).toContain('</details>')
})
it('keeps top-level thinking nodes intact while streaming html output', () => {
const html = renderMarkdownToHtml({
content: '<thinking>我们需要给出一个全面的回答',
final: false,
customHtmlTags: ['thinking'],
})
expect(html).toContain('data-markstream-custom-tag="thinking"')
expect(html).toContain('全面的回答')
expect(html).not.toContain('&lt;thinking&gt;')
})
it('emits math source and render shells so KaTeX can refresh incrementally', () => {
const html = renderMarkdownToHtml({
content: '$x = a$\n\n$$f(x) = x^2$$',
final: true,
})
expect(html).toContain('markstream-nested-math__source')
expect(html).toContain('markstream-nested-math__render')
expect(html).toContain('markstream-nested-math-block__source')
expect(html).toContain('markstream-nested-math-block__render')
})
it('keeps softbreak text visible and renders references like the runtime renderer', () => {
const html = renderMarkdownToHtml({
content: '第一行\n第二行[3]',
final: true,
})
expect(html).toContain('class="markstream-angular-text-node"')
expect(html).toContain('第一行\n第二行')
expect(html).toContain('<span class="markstream-nested-reference">3</span>')
expect(html).not.toContain('<sup class="markstream-nested-reference">')
})
it('sanitizes dangerous html while preserving safe attributes', () => {
const html = sanitizeHtmlFragment('<details open onclick="evil()"><summary>Safe</summary><a href="javascript:alert(1)" title="ok">Link</a><script>alert(1)</script></details>')
expect(html).toContain('<details open>')
expect(html).toContain('<summary>Safe</summary>')
expect(html).toContain('<a title="ok">Link</a>')
expect(html).not.toContain('onclick')
expect(html).not.toContain('javascript:')
expect(html).not.toContain('<script')
expect(html).not.toContain('alert(1)')
})
it('renders structured html wrappers while leaving blocked tags on the sanitized fallback path', () => {
const structuredHtml = renderMarkdownNodeToHtml(
{
type: 'html_block',
tag: 'span',
raw: '<span style="font-size: 12px;"></span>',
content: '<span style="font-size: 12px;"></span>',
attrs: [['style', 'font-size: 12px;']],
children: [
{
type: 'list',
raw: '',
ordered: false,
items: [
{
type: 'list_item',
raw: '',
children: [{ type: 'text', raw: '', content: 'alpha' }],
},
{
type: 'list_item',
raw: '',
children: [{ type: 'text', raw: '', content: 'beta' }],
},
],
},
],
} as any,
)
expect(structuredHtml).toContain('<span style="font-size: 12px;">')
expect(structuredHtml).toContain('<ul><li>alpha</li><li>beta</li></ul>')
const blockedHtml = renderMarkdownNodeToHtml(
{
type: 'html_block',
tag: 'script',
raw: '<script>\n\n- alpha\n\n</script>',
content: '<script>\n\n- alpha\n\n</script>',
children: [
{
type: 'list',
raw: '',
ordered: false,
items: [
{
type: 'list_item',
raw: '',
children: [{ type: 'text', raw: '', content: 'alpha' }],
},
],
},
],
} as any,
)
expect(blockedHtml).not.toContain('<ul>')
expect(blockedHtml).not.toContain('<li>')
expect(blockedHtml).not.toContain('<script')
const literalHtml = renderMarkdownNodeToHtml(
{
type: 'html_block',
tag: 'pre',
raw: '<pre>\n\n- alpha\n\n</pre>',
content: '<pre>\n\n- alpha\n\n</pre>',
children: [
{
type: 'list',
raw: '',
ordered: false,
items: [
{
type: 'list_item',
raw: '',
children: [{ type: 'text', raw: '', content: 'alpha' }],
},
],
},
],
} as any,
)
expect(literalHtml).not.toContain('<ul>')
expect(literalHtml).not.toContain('<li>')
expect(literalHtml).toContain('<pre>')
})
it('sanitizes dangerous attrs on structured html wrappers', () => {
const html = renderMarkdownNodeToHtml(
{
type: 'html_block',
tag: 'a',
raw: '<a href="javascript:alert(1)" onclick="alert(1)" data-safe="ok"></a>',
content: '<a href="javascript:alert(1)" onclick="alert(1)" data-safe="ok"></a>',
attrs: [
['href', 'javascript:alert(1)'],
['onclick', 'alert(1)'],
['data-safe', 'ok'],
],
children: [
{
type: 'paragraph',
raw: 'safe child',
children: [{ type: 'text', raw: 'safe child', content: 'safe child' }],
},
],
} as any,
)
expect(html).toContain('<a data-safe="ok">')
expect(html).toContain('<p>safe child</p>')
expect(html).not.toContain('onclick=')
expect(html).not.toContain('javascript:')
})
})
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import { resolveParsedNodes, splitParagraphChildren } from '../packages/markstream-angular/src/components/shared/node-helpers'
import { renderMarkdownToHtml } from '../packages/markstream-angular/src/renderMarkdownHtml'
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 paragraphChildren(markdown: string) {
const nodes = resolveParsedNodes({
content: markdown,
final: true,
}) as any[]
expect(nodes).toHaveLength(1)
expect(nodes[0]?.type).toBe('paragraph')
return nodes[0]?.children ?? []
}
describe('markstream-angular issue #386 paragraph segmentation regressions', () => {
it('keeps inline html embedded inside the same paragraph segment', () => {
const children = paragraphChildren('A<sup>[3]</sup>B')
const segments = splitParagraphChildren(children)
expect(segments).toHaveLength(1)
expect(segments[0]).toMatchObject({ kind: 'inline' })
expect((segments[0] as any).nodes.map((node: any) => node.type)).toEqual(['text', 'html_inline', 'text'])
})
it('keeps adjacent text around inline html in the same paragraph segment for the real issue-386 line', () => {
const children = paragraphChildren('测试<sup>[12]</sup>结束')
const segments = splitParagraphChildren(children)
expect(segments).toHaveLength(1)
expect(segments[0]).toMatchObject({ kind: 'inline' })
expect((segments[0] as any).nodes.map((node: any) => node.type)).toEqual(['text', 'html_inline', 'text'])
})
it('renders the real multiline input without leaking raw superscript syntax in html output', () => {
const html = renderMarkdownToHtml({
content: REAL_WORLD_MULTILINE_INPUT,
final: false,
})
expect(html).not.toContain('^[1]^')
expect(html).toContain('<sup>[3]</sup>')
expect(html).toContain('A</span><sup>[3]</sup><span class="markstream-angular-text-node">B')
expect(html).toContain('测试</span><sup>[12]</sup><span class="markstream-angular-text-node">结束')
expect(html).toContain('class="markstream-angular-text-node"')
})
})
@@ -0,0 +1,76 @@
import katex from 'katex'
import { afterEach, describe, expect, it, vi } from 'vitest'
class FakeKaTeXWorker {
onmessage: ((event: MessageEvent) => void) | null = null
onerror: ((event: ErrorEvent) => void) | null = null
postMessage(data: { id: string, content: string, displayMode: boolean }) {
queueMicrotask(() => {
try {
const html = katex.renderToString(data.content, {
throwOnError: true,
displayMode: data.displayMode,
output: 'html',
strict: 'ignore',
})
this.onmessage?.({
data: {
id: data.id,
html,
content: data.content,
displayMode: data.displayMode,
},
} as MessageEvent)
}
catch (error: any) {
this.onmessage?.({
data: {
id: data.id,
error: error?.message || String(error),
content: data.content,
displayMode: data.displayMode,
},
} as MessageEvent)
}
})
}
terminate() {}
}
let katexWorkerClient: typeof import('../packages/markstream-angular/src/workers/katexWorkerClient') | null = null
afterEach(() => {
katexWorkerClient?.clearKaTeXWorker()
katexWorkerClient = null
vi.resetModules()
})
describe('markstream-angular katex unicode unit render regression', () => {
it('enhances inline and block math with unicode unit glyphs through the worker path', async () => {
katexWorkerClient = await import('../packages/markstream-angular/src/workers/katexWorkerClient')
katexWorkerClient.setKaTeXWorker(new FakeKaTeXWorker() as unknown as Worker)
const { enhanceRenderedHtml } = await import('../packages/markstream-angular/src/enhanceRenderedHtml')
const root = document.createElement('div')
root.innerHTML = `
<div class="markstream-angular markdown-renderer">
<span class="markstream-nested-math" data-display="inline">
<span class="markstream-nested-math__source">c=0.75\\times10^3\\ \\text{J/(kg·℃)}</span>
<span class="markstream-nested-math__render"></span>
</span>
<div class="markstream-nested-math-block">
<pre class="markstream-nested-math-block__source"><code>Q_1=0.75\\times10^3\\ \\text{J/(kg·℃)}\\times1.1\\ \\text{kg}\\times40℃=3.3\\times 10^{4}\\ \\text{J}</code></pre>
<div class="markstream-nested-math-block__render"></div>
</div>
</div>
`
const shell = root.querySelector('.markstream-angular') as HTMLElement
await enhanceRenderedHtml(shell, { final: false })
expect(shell.querySelector('.markstream-nested-math__render .katex')).toBeTruthy()
expect(shell.querySelector('.markstream-nested-math-block__render .katex-display')).toBeTruthy()
})
})
@@ -0,0 +1,20 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
describe('markstream-angular link loading defaults', () => {
it('keeps the subtle loading hint template and CSS tokens in sync', () => {
const componentSource = readFileSync(resolve(process.cwd(), 'packages/markstream-angular/src/components/LinkNode/LinkNode.component.ts'), 'utf8')
const cssSource = readFileSync(resolve(process.cwd(), 'packages/markstream-angular/src/index.css'), 'utf8')
expect(componentSource).toContain('link-loading-indicator')
expect(componentSource).toContain('\'--underline-opacity\': \'0.35\'')
expect(componentSource).toContain('\'--underline-rest-opacity\': \'0.175\'')
expect(componentSource).toContain('\'--underline-duration\': \'1.6s\'')
expect(componentSource).toContain('\'--underline-timing\': \'ease-in-out\'')
expect(cssSource).toContain('.markstream-angular .link-loading-indicator')
expect(cssSource).toContain('markstream-angular-link-loading-pulse')
expect(cssSource).toContain('opacity: var(--underline-rest-opacity, 0.18);')
})
})
@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest'
import { parseNestedMarkdownToNodes } from '../packages/markstream-angular/src/parseNestedMarkdownToNodes'
describe('markstream-angular nested nodes helper', () => {
it('returns a shallow copy when nodes are already provided', () => {
const nodes = [
{
type: 'paragraph',
raw: '',
children: [
{ type: 'text', raw: '', content: 'hello' },
],
},
] as any[]
const parsed = parseNestedMarkdownToNodes({ nodes })
expect(parsed).toEqual(nodes)
expect(parsed).not.toBe(nodes)
})
it('prefers existing node children over reparsing content', () => {
const parsed = parseNestedMarkdownToNodes({
node: {
type: 'thinking',
raw: '',
content: 'should not be used',
children: [
{
type: 'paragraph',
raw: '',
children: [
{ type: 'text', raw: '', content: 'nested child' },
],
},
],
} as any,
})
expect(parsed).toHaveLength(1)
expect((parsed[0] as any).type).toBe('paragraph')
expect(((parsed[0] as any).children?.[0] as any)?.content).toBe('nested child')
})
it('parses streaming custom-tag content into stable nested nodes', () => {
const partial = parseNestedMarkdownToNodes(
{
content: '<thinking>streaming body',
},
{
cacheKey: 'markstream-angular-nested-nodes-streaming',
customHtmlTags: ['thinking'],
final: false,
},
) as any[]
expect(partial).toHaveLength(1)
expect(partial[0]?.type).toBe('thinking')
expect(partial[0]?.loading).toBe(true)
expect(String(partial[0]?.content || '')).toContain('streaming body')
})
it('keeps mermaid and code blocks available inside thinking content', () => {
const topLevel = parseNestedMarkdownToNodes(
{
content: `<thinking>
## Nested Mermaid
\`\`\`mermaid
flowchart TD
A --> B
\`\`\`
\`\`\`ts
export const ready = true
\`\`\`
</thinking>`,
},
{
cacheKey: 'markstream-angular-nested-nodes-thinking-heavy',
customHtmlTags: ['thinking'],
final: true,
},
) as any[]
expect(topLevel).toHaveLength(1)
expect(topLevel[0]?.type).toBe('thinking')
const innerNodes = parseNestedMarkdownToNodes(
{
node: topLevel[0],
},
{
cacheKey: 'markstream-angular-nested-nodes-thinking-heavy-inner',
customHtmlTags: ['thinking'],
final: true,
},
) as any[]
expect(innerNodes.map(node => node?.type)).toContain('heading')
const codeBlocks = innerNodes.filter(node => node?.type === 'code_block')
expect(codeBlocks).toHaveLength(2)
expect(codeBlocks.map(node => node?.language)).toEqual(['mermaid', 'ts'])
})
})
@@ -0,0 +1,175 @@
import { describe, expect, it } from 'vitest'
import { hasCompleteHtmlTagContent } from '../packages/markstream-angular/src/components/shared/node-helpers'
import {
coerceBuiltinHtmlNode,
coerceCustomHtmlNode,
resolveNodeOutletCodeMode,
resolveNodeOutletCustomComponent,
resolveNodeOutletCustomInputs,
} from '../packages/markstream-angular/src/components/shared/node-outlet-helpers'
class ExactLanguageComponent {}
class GenericCodeBlockComponent {}
class MermaidComponent {}
class D2Component {}
class D2LangComponent {}
describe('markstream-angular NodeOutlet', () => {
it('coerces custom html tags into tag-typed nodes for custom components', () => {
const node = {
type: 'html_block',
tag: 'thinking',
content: '<thinking>inner body</thinking>',
raw: '<thinking>inner body</thinking>',
} as any
expect((coerceCustomHtmlNode(node) as any).type).toBe('thinking')
expect((coerceCustomHtmlNode(node) as any).tag).toBe('thinking')
expect((coerceCustomHtmlNode(node) as any).content).toBe('inner body')
expect((coerceBuiltinHtmlNode(node, 'html_block') as any).type).toBe('html_block')
expect((coerceBuiltinHtmlNode(node, 'html_block') as any).content).toBe('<thinking>inner body</thinking>')
})
it('preserves standard html wrappers for builtin html nodes', () => {
const node = {
type: 'html_block',
tag: 'details',
content: '<details><summary>More</summary><p>Body</p></details>',
raw: '<details><summary>More</summary><p>Body</p></details>',
} as any
expect((coerceBuiltinHtmlNode(node, 'html_block') as any).content).toBe('<details><summary>More</summary><p>Body</p></details>')
})
it('only escapes malformed unknown html tags', () => {
expect(hasCompleteHtmlTagContent('<question>ok</question>', 'question')).toBe(true)
expect(hasCompleteHtmlTagContent('<question>ok', 'question')).toBe(false)
})
it('routes heavy node props by code block mode', () => {
const context = {
codeBlockProps: { showHeader: false },
mermaidProps: { renderDebounceMs: 180 },
d2Props: { themeId: 7 },
infographicProps: { showHeader: false },
events: {},
}
let node = {
type: 'code_block',
language: 'mermaid',
code: 'graph TD\nA-->B\n',
} as any
expect(resolveNodeOutletCodeMode(node, context as any)).toBe('mermaid')
expect(resolveNodeOutletCustomInputs(node, context as any)).toEqual({ renderDebounceMs: 180, estimatedPreviewHeightPx: 360 })
node = {
type: 'code_block',
language: 'd2',
code: 'a -> b',
} as any
expect(resolveNodeOutletCodeMode(node, context as any)).toBe('d2')
expect(resolveNodeOutletCustomInputs(node, context as any)).toEqual({ themeId: 7 })
node = {
type: 'code_block',
language: 'infographic',
code: 'infographic list-row-simple-horizontal-arrow',
} as any
expect(resolveNodeOutletCodeMode(node, context as any)).toBe('infographic')
expect(resolveNodeOutletCustomInputs(node, context as any)).toEqual({ showHeader: false, estimatedPreviewHeightPx: 360 })
node = {
type: 'code_block',
language: 'ts',
code: 'const value = 1',
} as any
expect(resolveNodeOutletCodeMode(node, context as any)).toBe('code')
expect(resolveNodeOutletCustomInputs(node, context as any)).toEqual({ showHeader: false })
})
it('prefers exact language custom components over code_block fallback', () => {
const context = {
customComponents: {
echarts: ExactLanguageComponent as any,
code_block: GenericCodeBlockComponent as any,
},
codeBlockProps: { showHeader: false },
events: {},
} as any
let node = {
type: 'code_block',
language: 'echarts',
code: 'option = {}',
} as any
expect(resolveNodeOutletCustomComponent(node, context)).toBe(ExactLanguageComponent)
expect(resolveNodeOutletCustomInputs(node, context)).toEqual({ showHeader: false })
node = {
type: 'code_block',
language: 'ts',
code: 'const value = 1',
} as any
expect(resolveNodeOutletCustomComponent(node, context)).toBe(GenericCodeBlockComponent)
expect(resolveNodeOutletCustomInputs(node, context)).toEqual({ showHeader: false })
})
it('keeps specialized mermaid routing ahead of code_block fallback', () => {
const context = {
customComponents: {
mermaid: MermaidComponent as any,
code_block: GenericCodeBlockComponent as any,
},
mermaidProps: { renderDebounceMs: 180 },
events: {},
} as any
const node = {
type: 'code_block',
language: 'mermaid',
code: 'graph TD\nA-->B\n',
} as any
expect(resolveNodeOutletCustomComponent(node, context)).toBe(MermaidComponent)
expect(resolveNodeOutletCustomInputs(node, context)).toEqual({ renderDebounceMs: 180, estimatedPreviewHeightPx: 360 })
})
it('injects stable preview height estimates for angular mermaid and infographic custom inputs', () => {
const context = {
events: {},
} as any
let node = {
type: 'code_block',
language: 'mermaid',
code: 'flowchart TD\nA-->B\nB-->C\nC-->D\nD-->E\nE-->F\nF-->G\nG-->H\nH-->I\nI-->J\nJ-->K\nK-->L\n',
} as any
expect(resolveNodeOutletCustomInputs(node, context)).toEqual({ estimatedPreviewHeightPx: 500 })
node = {
type: 'code_block',
language: 'infographic',
code: ['# Release progress', '- Plan: complete', '- Build: active', '- Verify: pending'].join('\n'),
} as any
expect(resolveNodeOutletCustomInputs(node, context)).toEqual({ estimatedPreviewHeightPx: 500 })
})
it('lets d2lang exact overrides beat d2 fallback while keeping d2 inputs', () => {
const context = {
customComponents: {
d2: D2Component as any,
d2lang: D2LangComponent as any,
},
d2Props: { themeId: 7 },
events: {},
} as any
const node = {
type: 'code_block',
language: 'd2lang',
code: 'a -> b',
} as any
expect(resolveNodeOutletCustomComponent(node, context)).toBe(D2LangComponent)
expect(resolveNodeOutletCustomInputs(node, context)).toEqual({ themeId: 7 })
})
})
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import {
isMediaOnlyParagraphNodes,
normalizeMediaOnlyParagraphNodes,
splitParagraphChildren,
} from '../packages/markstream-angular/src/components/shared/node-helpers'
describe('markstream-angular paragraph media-only links', () => {
it('keeps image links in the same inline segment and normalizes separator whitespace', () => {
const children = [
{
type: 'link',
href: 'https://www.npmjs.com/package/markstream-vue',
title: null,
text: 'NPM version',
raw: '',
children: [
{
type: 'image',
src: 'https://img.shields.io/npm/v/markstream-vue?color=a1b858&label=',
alt: 'NPM version',
title: 'NPM version',
raw: '',
},
],
},
{
type: 'text',
content: '\n',
raw: '\n',
},
{
type: 'link',
href: 'README.zh-CN.md',
title: null,
text: '中文版',
raw: '',
children: [
{
type: 'image',
src: 'https://img.shields.io/badge/docs-中文文档-blue',
alt: '中文版',
title: '中文版',
raw: '',
},
],
},
] as any[]
const segments = splitParagraphChildren(children as any)
expect(segments).toHaveLength(1)
expect(segments[0]).toMatchObject({ kind: 'inline' })
const inlineNodes = (segments[0] as any).nodes
expect(isMediaOnlyParagraphNodes(inlineNodes)).toBe(true)
const normalized = normalizeMediaOnlyParagraphNodes(inlineNodes)
expect(normalized.map(node => node.type)).toEqual(['link', 'text', 'link'])
expect((normalized[1] as any).content).toBe(' ')
expect(((normalized[0] as any).children[0] as any).type).toBe('image')
expect(((normalized[2] as any).children[0] as any).type).toBe('image')
})
})
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest'
import {
computeLiveRange,
DEFAULT_NODE_HEIGHT,
estimateHeightRange,
resolveAverageNodeHeight,
resolveDeferNodes,
resolveVirtualizationEnabled,
} from '../packages/markstream-angular/src/components/shared/render-window'
describe('markstream-angular render window helpers', () => {
it('computes a bounded live range around the focus index', () => {
expect(computeLiveRange(0, 0, 5, 2)).toEqual({ start: 0, end: 0 })
expect(computeLiveRange(10, 0, 5, 2)).toEqual({ start: 0, end: 4 })
expect(computeLiveRange(10, 9, 5, 2)).toEqual({ start: 6, end: 10 })
expect(computeLiveRange(100, 50, 9, 3)).toEqual({ start: 46, end: 55 })
})
it('estimates placeholder heights from measured nodes and falls back to the average', () => {
const nodeHeights = new Map<number, number>([
[0, 40],
[1, 24],
[4, 56],
])
const average = resolveAverageNodeHeight(nodeHeights)
expect(average).toBeCloseTo(40, 0)
expect(resolveAverageNodeHeight(new Map())).toBe(DEFAULT_NODE_HEIGHT)
expect(estimateHeightRange(0, 3, nodeHeights, average)).toBeCloseTo(104, 0)
})
it('only defers nodes when virtualization is off and viewport priority is enabled', () => {
expect(resolveVirtualizationEnabled(500, 320)).toBe(true)
expect(resolveVirtualizationEnabled(100, 320)).toBe(false)
expect(resolveVirtualizationEnabled(500, 0)).toBe(false)
expect(resolveVirtualizationEnabled(500, -1)).toBe(false)
expect(resolveDeferNodes({
parsedNodeCount: 120,
virtualizationEnabled: false,
viewportPriority: true,
deferNodesUntilVisible: true,
maxLiveNodes: 320,
})).toBe(true)
expect(resolveDeferNodes({
parsedNodeCount: 1200,
virtualizationEnabled: false,
viewportPriority: true,
deferNodesUntilVisible: true,
maxLiveNodes: 320,
})).toBe(false)
expect(resolveDeferNodes({
parsedNodeCount: 120,
virtualizationEnabled: true,
viewportPriority: true,
deferNodesUntilVisible: true,
maxLiveNodes: 320,
})).toBe(false)
expect(resolveDeferNodes({
parsedNodeCount: 120,
virtualizationEnabled: false,
viewportPriority: true,
deferNodesUntilVisible: true,
maxLiveNodes: 0,
})).toBe(false)
})
})
@@ -0,0 +1,56 @@
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const angularCompilerEntry = require.resolve('@angular/compiler', {
paths: [resolve(process.cwd(), 'node_modules/.pnpm/node_modules')],
})
await import(angularCompilerEntry)
const { TextNodeComponent } = await import('../packages/markstream-angular/src/components/TextNode/TextNode.component')
describe('markstream-angular text node streaming consistency', () => {
it('settles a finished text delta when a later stream tick keeps the same text', () => {
const textStreamState = new Map<string, string>()
const component = new TextNodeComponent()
component.indexKey = 'stream-0'
component.context = {
events: {},
textStreamState,
typewriter: true,
streamRenderVersion: 1,
}
component.node = {
type: 'text',
content: '记忆化递归(动态规划)',
raw: '记忆化递归(动态规划)',
} as any
textStreamState.set('stream-0', '记忆化递归(动态规划')
component.ngOnChanges()
expect(component.settledText).toBe('记忆化递归(动态规划')
expect(component.streamedDelta).toBe(')')
component.context = {
...component.context,
streamRenderVersion: 2,
}
component.ngOnChanges()
expect(component.settledText).toBe('记忆化递归(动态规划)')
expect(component.streamedDelta).toBe('')
})
it('ships pre-wrap text styles so softbreaks stay visible in Angular', () => {
const css = readFileSync(
resolve(process.cwd(), 'packages/markstream-angular/src/index.css'),
'utf8',
)
expect(css).toContain('.markstream-angular .markstream-angular-text-node')
expect(css).toContain('white-space: pre-wrap;')
})
})
@@ -0,0 +1,764 @@
/**
* @vitest-environment node
*/
import { createRequire } from 'node:module'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const packageRequire = createRequire(new URL('../packages/markstream-react/package.json', import.meta.url))
const React = packageRequire('react') as typeof import('react')
const { renderToStaticMarkup } = packageRequire('react-dom/server') as typeof import('react-dom/server')
function textNode(content: string) {
return {
type: 'text',
content,
raw: content,
}
}
function paragraphNode(content: string) {
return {
type: 'paragraph',
raw: content,
children: [textNode(content)],
}
}
function listItemNode(content: string) {
return {
type: 'list_item',
raw: content,
children: [paragraphNode(content)],
}
}
function definitionListNode() {
return {
type: 'definition_list',
items: [
{
type: 'definition_item',
term: [textNode('Term')],
definition: [paragraphNode('Definition')],
},
],
}
}
function tableNode() {
return {
type: 'table',
header: {
cells: [
{ children: [textNode('Name')] },
{ children: [textNode('Value')] },
],
},
rows: [
{
cells: [
{ children: [textNode('Alpha')] },
{ children: [textNode('One')] },
],
},
],
}
}
function vmrContainerNode() {
return {
type: 'vmr_container',
name: 'tip',
attrs: [['data-kind', 'tip']],
children: [paragraphNode('Container body')],
}
}
function insightNode(label: string, body: string) {
return {
type: 'insight',
raw: label,
label,
children: [paragraphNode(body)],
}
}
function customThinkingNode(scope: string, body: string) {
return {
type: 'html_block',
tag: 'thinking',
attrs: [
['data-tone', 'calm'],
['data-scope', scope],
],
content: `<thinking data-tone="calm" data-scope="${scope}">${body}</thinking>`,
raw: `<thinking data-tone="calm" data-scope="${scope}">${body}</thinking>`,
}
}
function createRenderCtx(serverEntry: any) {
return {
customComponents: {},
events: {},
typewriter: false,
codeBlockStream: true,
showTooltips: false,
renderCodeBlocksAsPre: false,
codeBlockThemes: {},
customId: 'matrix',
renderNode: serverEntry.renderNode,
} as any
}
function createMatrixCases(entry: any, serverEntry: any) {
const ctx = createRenderCtx(serverEntry)
const withCtx = (props: Record<string, any>) => ({
...props,
ctx,
renderNode: serverEntry.renderNode,
indexKey: 'matrix',
customId: 'matrix',
})
return [
{
name: 'NodeRenderer',
expected: 'Matrix Heading',
element: React.createElement(entry.NodeRenderer, {
content: '# Matrix Heading\n\nParagraph text',
final: true,
}),
},
{
name: 'AdmonitionNode',
expected: 'Alert body',
element: React.createElement(entry.AdmonitionNode, withCtx({
node: {
type: 'admonition',
kind: 'note',
children: [paragraphNode('Alert body')],
},
})),
},
{
name: 'BlockquoteNode',
expected: 'Quoted body',
element: React.createElement(entry.BlockquoteNode, withCtx({
node: {
type: 'blockquote',
children: [paragraphNode('Quoted body')],
},
})),
},
{
name: 'CheckboxNode',
expected: 'checkbox',
element: React.createElement(entry.CheckboxNode, {
node: { type: 'checkbox', checked: true },
}),
},
{
name: 'CodeBlockNode',
expected: 'data-ssr-fallback="code-block"',
element: React.createElement(entry.CodeBlockNode, {
node: {
type: 'code_block',
language: 'ts',
code: 'const answer = 42',
raw: 'const answer = 42',
},
}),
},
{
name: 'MarkdownCodeBlockNode',
expected: 'data-ssr-fallback="markdown-code-block"',
element: React.createElement(entry.MarkdownCodeBlockNode, {
node: {
type: 'code_block',
language: 'md',
code: '# nested markdown',
raw: '# nested markdown',
},
}),
},
{
name: 'PreCodeNode',
expected: 'const fallback = true',
element: React.createElement(entry.PreCodeNode, {
node: {
type: 'code_block',
language: 'js',
code: 'const fallback = true',
raw: 'const fallback = true',
},
}),
},
{
name: 'D2BlockNode',
expected: 'data-ssr-fallback="d2"',
element: React.createElement(entry.D2BlockNode, {
node: {
type: 'code_block',
language: 'd2',
code: 'x -> y',
raw: 'x -> y',
},
}),
},
{
name: 'MermaidBlockNode',
expected: 'data-ssr-fallback="mermaid"',
element: React.createElement(entry.MermaidBlockNode, {
node: {
type: 'code_block',
language: 'mermaid',
code: 'graph TD;A-->B;',
raw: 'graph TD;A-->B;',
},
}),
},
{
name: 'InfographicBlockNode',
expected: 'data-ssr-fallback="infographic"',
element: React.createElement(entry.InfographicBlockNode, {
node: {
type: 'code_block',
language: 'infographic',
code: '{"title":"Demo"}',
raw: '{"title":"Demo"}',
},
}),
},
{
name: 'DefinitionListNode',
expected: 'Definition',
element: React.createElement(entry.DefinitionListNode, withCtx({
node: definitionListNode(),
})),
},
{
name: 'EmojiNode',
expected: 'sparkles',
element: React.createElement(entry.EmojiNode, {
node: { type: 'emoji', name: 'sparkles' },
}),
},
{
name: 'EmphasisNode',
expected: 'Emphasis',
element: React.createElement(entry.EmphasisNode, {
node: { type: 'emphasis' },
children: 'Emphasis',
}),
},
{
name: 'StrongNode',
expected: 'Strong',
element: React.createElement(entry.StrongNode, {
node: { type: 'strong' },
children: 'Strong',
}),
},
{
name: 'StrikethroughNode',
expected: 'Strike',
element: React.createElement(entry.StrikethroughNode, {
node: { type: 'strikethrough' },
children: 'Strike',
}),
},
{
name: 'HighlightNode',
expected: 'Highlight',
element: React.createElement(entry.HighlightNode, {
node: { type: 'highlight' },
children: 'Highlight',
}),
},
{
name: 'InsertNode',
expected: 'Insert',
element: React.createElement(entry.InsertNode, {
node: { type: 'insert' },
children: 'Insert',
}),
},
{
name: 'SubscriptNode',
expected: 'Sub',
element: React.createElement(entry.SubscriptNode, {
node: { type: 'subscript' },
children: 'Sub',
}),
},
{
name: 'SuperscriptNode',
expected: 'Sup',
element: React.createElement(entry.SuperscriptNode, {
node: { type: 'superscript' },
children: 'Sup',
}),
},
{
name: 'FootnoteNode',
expected: 'Footnote body',
element: React.createElement(entry.FootnoteNode, withCtx({
node: {
type: 'footnote',
id: '1',
children: [paragraphNode('Footnote body')],
},
})),
},
{
name: 'FootnoteReferenceNode',
expected: 'fnref-1',
element: React.createElement(entry.FootnoteReferenceNode, {
node: { type: 'footnote_reference', id: '1' },
}),
},
{
name: 'FootnoteAnchorNode',
expected: 'fnref-1',
element: React.createElement(entry.FootnoteAnchorNode, {
node: { type: 'footnote_anchor', id: '1' },
}),
},
{
name: 'HardBreakNode',
expected: '<br/>',
element: React.createElement(entry.HardBreakNode, {
node: { type: 'hardbreak' },
}),
},
{
name: 'HeadingNode',
expected: 'Heading',
element: React.createElement(entry.HeadingNode, {
node: { type: 'heading', level: 2 },
children: 'Heading',
}),
},
{
name: 'ParagraphNode',
expected: 'Paragraph',
element: React.createElement(entry.ParagraphNode, {
node: { type: 'paragraph' },
children: 'Paragraph',
}),
},
{
name: 'HtmlBlockNode',
expected: 'Block HTML',
element: React.createElement(entry.HtmlBlockNode, {
node: { type: 'html_block', content: '<div><strong>Block HTML</strong></div>' },
}),
},
{
name: 'HtmlInlineNode',
expected: 'Inline HTML',
element: React.createElement(entry.HtmlInlineNode, {
node: { type: 'html_inline', content: '<span>Inline HTML</span>' },
}),
},
{
name: 'ImageNode',
expected: 'https://example.com/demo.png',
element: React.createElement(entry.ImageNode, {
node: {
type: 'image',
src: 'https://example.com/demo.png',
alt: 'Demo image',
title: 'Demo image',
raw: '![Demo image](https://example.com/demo.png)',
},
}),
},
{
name: 'InlineCodeNode',
expected: 'inline-demo',
element: React.createElement(entry.InlineCodeNode, {
node: { type: 'inline_code', code: 'inline-demo' },
}),
},
{
name: 'LinkNode',
expected: 'https://example.com',
element: React.createElement(entry.LinkNode, {
node: {
type: 'link',
href: 'https://example.com',
title: 'Example',
text: 'Example',
raw: '[Example](https://example.com)',
children: [textNode('Example')],
},
children: 'Example',
}),
},
{
name: 'ListItemNode',
expected: 'List item',
element: React.createElement(entry.ListItemNode, {
node: { type: 'list_item' },
children: 'List item',
}),
},
{
name: 'ListNode',
expected: 'List body',
element: React.createElement(entry.ListNode, withCtx({
node: {
type: 'list',
ordered: false,
items: [listItemNode('List body')],
},
})),
},
{
name: 'MathBlockNode',
expected: 'math-block',
element: React.createElement(entry.MathBlockNode, {
node: {
type: 'math_block',
content: 'x^2',
raw: '$$x^2$$',
},
}),
},
{
name: 'MathInlineNode',
expected: 'math-inline-wrapper',
element: React.createElement(entry.MathInlineNode, {
node: {
type: 'math_inline',
content: 'a+b',
raw: '$a+b$',
markup: '$',
},
}),
},
{
name: 'ReferenceNode',
expected: 'ref-1',
element: React.createElement(entry.ReferenceNode, {
node: { type: 'reference', id: 'ref-1' },
}),
},
{
name: 'TableNode',
expected: 'Alpha',
element: React.createElement(entry.TableNode, withCtx({
node: tableNode(),
})),
},
{
name: 'TextNode',
expected: 'Text body',
element: React.createElement(entry.TextNode, {
node: { type: 'text', content: 'Text body' },
}),
},
{
name: 'ThematicBreakNode',
expected: '<hr',
element: React.createElement(entry.ThematicBreakNode, {
node: { type: 'thematic_break' },
}),
},
{
name: 'Tooltip',
expected: '<section data-ssr-export="Tooltip"></section>',
element: React.createElement(entry.Tooltip, {
visible: true,
anchorEl: null,
content: 'Tooltip body',
}),
},
{
name: 'HtmlPreviewFrame',
expected: '<section data-ssr-export="HtmlPreviewFrame"></section>',
element: React.createElement(entry.HtmlPreviewFrame, {
code: '<p>Preview</p>',
}),
},
{
name: 'VmrContainerNode',
expected: 'Container body',
element: React.createElement(entry.VmrContainerNode, withCtx({
node: vmrContainerNode(),
})),
},
{
name: 'FallbackComponent',
expected: 'Unsupported node type',
element: React.createElement(entry.FallbackComponent, {
node: { type: 'mystery' },
}),
},
]
}
function renderExport(name: string, element: React.ReactElement) {
return renderToStaticMarkup(
React.createElement('section', { 'data-ssr-export': name }, element),
)
}
describe('markstream-react next/server SSR', () => {
beforeEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
vi.resetModules()
})
afterEach(async () => {
const serverEntry = await import('../packages/markstream-react/src/server')
serverEntry.clearGlobalCustomComponents()
serverEntry.removeCustomComponents?.('next-ssr-lab')
serverEntry.removeCustomComponents?.('next-ssr-lab-alt')
serverEntry.removeCustomComponents?.('server-ssr-lab')
serverEntry.removeCustomComponents?.('server-ssr-lab-alt')
vi.useRealTimers()
vi.unstubAllGlobals()
vi.resetModules()
})
it('keeps root, next, and server entry imports safe', async () => {
const entries = [
await import('../packages/markstream-react/src/index'),
await import('../packages/markstream-react/src/next'),
await import('../packages/markstream-react/src/server'),
]
expect(typeof entries[0].default).toBe('function')
expect(typeof entries[1].default).toBe('function')
expect(typeof entries[2].default).toBe('function')
}, 15000)
it('renders the next entry export matrix as SSR-safe HTML', async () => {
const nextEntry = await import('../packages/markstream-react/src/next')
const serverEntry = await import('../packages/markstream-react/src/server')
for (const item of createMatrixCases(nextEntry, serverEntry)) {
const html = renderExport(item.name, item.element)
expect(html).toContain(`data-ssr-export="${item.name}"`)
expect(html).toContain(item.expected)
}
})
it('renders the server entry export matrix as SSR-safe HTML', async () => {
const serverEntry = await import('../packages/markstream-react/src/server')
for (const item of createMatrixCases(serverEntry, serverEntry)) {
const html = renderExport(item.name, item.element)
expect(html).toContain(`data-ssr-export="${item.name}"`)
expect(html).toContain(item.expected)
}
})
it('keeps closed unknown tags as raw HTML and escapes malformed ones in the server entry', async () => {
const serverEntry = await import('../packages/markstream-react/src/server')
const closedHtml = renderToStaticMarkup(
React.createElement(serverEntry.NodeRenderer, {
content: '<question>ok</question>',
final: true,
}),
)
expect(closedHtml).toContain('<question>ok</question>')
const malformedHtml = renderToStaticMarkup(
React.createElement(serverEntry.NodeRenderer, {
content: '<question>ok',
final: true,
}),
)
expect(malformedHtml).toContain('&lt;question&gt;ok')
expect(malformedHtml).not.toContain('&amp;lt;')
})
it('renders structured html wrappers on the server without structuring blocked tags', async () => {
const serverEntry = await import('../packages/markstream-react/src/server')
const structuredHtml = renderToStaticMarkup(
React.createElement(serverEntry.NodeRenderer, {
nodes: [
{
type: 'html_block',
tag: 'span',
attrs: [['style', 'font-size: 12px;']],
content: '<span style="font-size: 12px;"></span>',
children: [
{
type: 'list',
ordered: false,
items: [listItemNode('alpha'), listItemNode('beta')],
},
],
} as any,
],
}),
)
expect(structuredHtml).toContain('<span')
expect(structuredHtml).toContain('font-size:12px')
expect(structuredHtml).toContain('<ul')
expect(structuredHtml).toContain('alpha')
expect(structuredHtml).toContain('beta')
const blockedHtml = renderToStaticMarkup(
React.createElement(serverEntry.NodeRenderer, {
nodes: [
{
type: 'html_block',
tag: 'script',
content: '<script>\n\n- alpha\n\n</script>',
children: [
{
type: 'list',
ordered: false,
items: [listItemNode('alpha')],
},
],
} as any,
],
}),
)
expect(blockedHtml).not.toContain('<ul>')
expect(blockedHtml).not.toContain('<li>')
})
it('renders custom overrides, custom tags, and heavy-node fallbacks through next and server entries', async () => {
const nextEntry = await import('../packages/markstream-react/src/next')
const serverEntry = await import('../packages/markstream-react/src/server')
const renderCustomChildren = (props: any) => (
Array.isArray(props.node?.children) && props.renderNode && props.ctx
? props.node.children.map((child: any, idx: number) => (
React.createElement(React.Fragment, { key: `${String(props.indexKey ?? 'custom')}-${idx}` }, props.renderNode(child, `${String(props.indexKey ?? 'custom')}-${idx}`, props.ctx))
))
: null
)
const readAttr = (node: any, name: string) => {
if (!node?.attrs)
return undefined
if (Array.isArray(node.attrs))
return node.attrs.find((item: [string, string | null]) => item[0] === name)?.[1] ?? undefined
return node.attrs[name]
}
serverEntry.setCustomComponents('server-ssr-lab', {
paragraph: ({ node }: any) => React.createElement('div', { 'data-ssr-status': 'paragraph-override' }, node.raw),
insight: (props: any) => React.createElement(
'section',
{ 'data-ssr-status': 'server-custom-node' },
React.createElement('div', { 'data-ssr-status': 'server-custom-node-children' }, renderCustomChildren(props)),
),
thinking: ({ node }: any) => React.createElement('aside', {
'data-ssr-status': 'thinking-tag',
'data-ssr-tone': readAttr(node, 'data-tone'),
'data-ssr-scope': readAttr(node, 'data-scope'),
}, node.content),
})
serverEntry.setCustomComponents('server-ssr-lab-alt', {
paragraph: ({ node }: any) => React.createElement('div', { 'data-ssr-status': 'server-paragraph-alt' }, node.raw),
})
const serverHtml = renderToStaticMarkup(
React.createElement(React.Fragment, null, React.createElement(serverEntry.NodeRenderer, {
customId: 'server-ssr-lab',
customHtmlTags: ['thinking'],
nodes: [
paragraphNode('Paragraph override') as any,
insightNode('Server custom node', 'Server child body') as any,
customThinkingNode('server-primary', 'Server tag body') as any,
{
type: 'code_block',
language: 'mermaid',
code: 'graph TD;A-->B;',
raw: 'graph TD;A-->B;',
} as any,
{
type: 'code_block',
language: 'd2',
code: 'x -> y',
raw: 'x -> y',
} as any,
{
type: 'code_block',
language: 'infographic',
code: '{"title":"SSR"}',
raw: '{"title":"SSR"}',
} as any,
],
}), React.createElement(serverEntry.NodeRenderer, {
customId: 'server-ssr-lab-alt',
customHtmlTags: ['thinking'],
nodes: [paragraphNode('Paragraph alt') as any],
})),
)
expect(serverHtml).toContain('data-ssr-status="paragraph-override"')
expect(serverHtml).toContain('data-ssr-status="server-paragraph-alt"')
expect(serverHtml).toContain('data-ssr-status="server-custom-node-children"')
expect(serverHtml).toContain('Server child body')
expect(serverHtml).toContain('data-ssr-status="thinking-tag"')
expect(serverHtml).toContain('data-ssr-tone="calm"')
expect(serverHtml).toContain('data-ssr-scope="server-primary"')
expect(serverHtml).toContain('data-ssr-fallback="mermaid"')
expect(serverHtml).toContain('data-ssr-fallback="d2"')
expect(serverHtml).toContain('data-ssr-fallback="infographic"')
nextEntry.setCustomComponents('next-ssr-lab', {
paragraph: ({ node }: any) => React.createElement('div', { 'data-ssr-status': 'next-paragraph-override' }, node.raw),
insight: (props: any) => React.createElement(
'section',
{ 'data-ssr-status': 'next-custom-node' },
React.createElement('div', { 'data-ssr-status': 'next-custom-node-children' }, renderCustomChildren(props)),
),
thinking: ({ node }: any) => React.createElement('aside', {
'data-ssr-status': 'next-thinking-tag',
'data-ssr-tone': readAttr(node, 'data-tone'),
'data-ssr-scope': readAttr(node, 'data-scope'),
}, node.content),
})
nextEntry.setCustomComponents('next-ssr-lab-alt', {
paragraph: ({ node }: any) => React.createElement('div', { 'data-ssr-status': 'next-paragraph-alt' }, node.raw),
})
const nextHtml = renderToStaticMarkup(
React.createElement(React.Fragment, null, React.createElement(nextEntry.NodeRenderer, {
customId: 'next-ssr-lab',
customHtmlTags: ['thinking'],
nodes: [
paragraphNode('Paragraph override') as any,
insightNode('Next custom node', 'Next child body') as any,
customThinkingNode('next-primary', 'Next tag body') as any,
{
type: 'code_block',
language: 'mermaid',
code: 'graph TD;A-->B;',
raw: 'graph TD;A-->B;',
} as any,
],
}), React.createElement(nextEntry.NodeRenderer, {
customId: 'next-ssr-lab-alt',
customHtmlTags: ['thinking'],
nodes: [paragraphNode('Paragraph alt') as any],
})),
)
expect(nextHtml).toContain('data-ssr-status="next-paragraph-override"')
expect(nextHtml).toContain('data-ssr-status="next-paragraph-alt"')
expect(nextHtml).toContain('data-ssr-status="next-custom-node-children"')
expect(nextHtml).toContain('Next child body')
expect(nextHtml).toContain('data-ssr-status="next-thinking-tag"')
expect(nextHtml).toContain('data-ssr-tone="calm"')
expect(nextHtml).toContain('data-ssr-scope="next-primary"')
expect(nextHtml).toContain('data-ssr-fallback="mermaid"')
})
})
@@ -0,0 +1,55 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Import after mocks
import MathBlockNode from '../src/components/MathBlockNode/MathBlockNode.vue'
import { flushAll } from './setup/flush-all'
// Mock getKatex to return a lightweight renderer
vi.mock('../src/components/MathInlineNode/katex', () => ({
getKatexSync: () => null,
getKatex: async () => ({
renderToString: (content: string, opts: any) => `<span class="katex ${opts?.displayMode ? 'block' : 'inline'}">${content}</span>`,
}),
}))
// Mock worker client to simulate worker busy so component falls back to main thread
vi.mock('../src/workers/katexWorkerClient', async () => {
const actual: any = await vi.importActual('../src/workers/katexWorkerClient')
return {
...actual,
WORKER_BUSY_CODE: 'WORKER_BUSY',
renderKaTeXWithBackpressure: async () => {
const err: any = new Error('Worker busy')
err.code = 'WORKER_BUSY'
err.name = 'WorkerBusy'
throw err
},
}
})
describe('mathBlockNode busy worker fallback', () => {
beforeEach(() => {
vi.useRealTimers()
})
it('falls back to sync KaTeX render instead of raw when worker is busy', async () => {
const wrapper = mount(MathBlockNode as any, {
props: {
node: {
type: 'math_block',
content: 'a^2 + b^2 = c^2',
raw: '$$a^2 + b^2 = c^2$$',
loading: true,
},
},
})
await flushAll()
const html = wrapper.html()
// Should contain our mocked KaTeX HTML
expect(html).toContain('katex block')
// Should not show raw text fallback
expect(html).not.toContain('$$a^2 + b^2 = c^2$$')
})
})
@@ -0,0 +1,133 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
const mathNode = {
type: 'math_block',
content: '\\sum_{n=1}^{3} n = 6',
raw: '$$\\sum_{n=1}^{3} n = 6$$',
markup: '$$',
loading: false,
} as const
const katexRenderer = {
renderToString(content: string, options?: { displayMode?: boolean }) {
return options?.displayMode
? `<span class="katex-display">${content}</span>`
: `<span class="katex">${content}</span>`
},
}
const browserKeys = [
'window',
'document',
'navigator',
'self',
'Element',
'HTMLElement',
'SVGElement',
'Node',
'Text',
'Comment',
'Document',
'MutationObserver',
'Event',
'CustomEvent',
'getComputedStyle',
'requestAnimationFrame',
'cancelAnimationFrame',
] as const
async function withGlobalOverrides<T>(values: Partial<Record<(typeof browserKeys)[number], unknown>>, run: () => Promise<T>) {
const previous = new Map<string, PropertyDescriptor | undefined>()
for (const key of browserKeys) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key))
if (!(key in values))
continue
Object.defineProperty(globalThis, key, {
configurable: true,
writable: true,
value: values[key],
})
}
try {
return await run()
}
finally {
for (const [key, descriptor] of previous) {
if (descriptor) {
Object.defineProperty(globalThis, key, descriptor)
}
else {
Reflect.deleteProperty(globalThis, key)
}
}
}
}
async function renderServerMathBlock() {
return withGlobalOverrides(
Object.fromEntries(browserKeys.map(key => [key, undefined])) as Partial<Record<(typeof browserKeys)[number], undefined>>,
async () => {
vi.resetModules()
const vue = await import('vue')
const { renderToString } = await import('vue/server-renderer')
const { setKatexLoader } = await import('../src/components/MathInlineNode/katex')
const MathBlockNode = (await import('../src/components/MathBlockNode/MathBlockNode.vue')).default
setKatexLoader(() => katexRenderer)
const app = vue.createSSRApp({
render: () => vue.h(MathBlockNode, { node: mathNode }),
})
return renderToString(app)
},
)
}
describe('mathBlockNode hydration', () => {
afterEach(() => {
document.body.innerHTML = ''
vi.restoreAllMocks()
vi.resetModules()
})
it('hydrates server-rendered KaTeX markup without mismatch warnings', async () => {
const serverHtml = await renderServerMathBlock()
expect(serverHtml).toContain('data-markstream-mode="katex"')
expect(serverHtml).toContain('katex-display')
document.body.innerHTML = `<div id="app">${serverHtml}</div>`
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.resetModules()
const vue = await import('vue')
const { setKatexLoader } = await import('../src/components/MathInlineNode/katex')
const MathBlockNode = (await import('../src/components/MathBlockNode/MathBlockNode.vue')).default
setKatexLoader(() => katexRenderer)
const app = vue.createSSRApp({
render: () => vue.h(MathBlockNode, { node: mathNode }),
})
const container = document.querySelector('#app') as HTMLElement
app.mount(container)
await vue.nextTick()
await Promise.resolve()
const diagnostics = [
...consoleError.mock.calls.flat(),
...consoleWarn.mock.calls.flat(),
].join(' ')
expect(container.innerHTML).toContain('katex-display')
expect(diagnostics).not.toMatch(/Hydration/i)
expect(diagnostics).not.toMatch(/mismatch/i)
})
})
@@ -0,0 +1,44 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import MathBlockNode from '../src/components/MathBlockNode/MathBlockNode.vue'
import { flushAll } from './setup/flush-all'
const mocks = vi.hoisted(() => ({
renderKaTeXWithBackpressure: vi.fn(() => new Promise<string>(() => {})),
}))
vi.mock('../src/components/MathInlineNode/katex', () => ({
getKatexSync: () => null,
getKatex: async () => null,
}))
vi.mock('../src/workers/katexWorkerClient', async () => {
const actual: any = await vi.importActual('../src/workers/katexWorkerClient')
return {
...actual,
renderKaTeXWithBackpressure: mocks.renderKaTeXWithBackpressure,
}
})
describe('mathBlockNode loading state', () => {
it('keeps loading math blocks in loading mode instead of flashing raw fallback', async () => {
const wrapper = mount(MathBlockNode as any, {
props: {
node: {
type: 'math_block',
content: '\\begin{pmatrix}\na & b \\\\\nc & d',
raw: '\\[\n\\begin{pmatrix}\na & b \\\\\nc & d\n\\]',
loading: true,
},
},
})
await flushAll()
expect(mocks.renderKaTeXWithBackpressure).toHaveBeenCalled()
expect(wrapper.attributes('data-markstream-mode')).toBe('loading')
expect(wrapper.find('.math-loading-overlay').exists()).toBe(true)
expect(wrapper.find('.math-block__fallback').exists()).toBe(false)
expect(wrapper.text()).not.toContain('\\begin{pmatrix}')
})
})
@@ -0,0 +1,53 @@
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
import { describe, expect, it } from 'vitest'
describe('math block with spaces between characters', () => {
it('should parse $$ with spaced subscript as math_block', () => {
const md = getMarkdown('test')
const markdown = `$$
f _ { t r a n s f e r } : M _ { s } M _ { l }
$$`
const tokens = md.parse(markdown, {})
console.log('=== Tokens ===')
tokens.forEach((token, i) => {
console.log(` ${i}: type=${token.type} tag=${token.tag} content="${String(token.content || '').slice(0, 60)}" markup=${token.markup}`)
})
const mathBlocks = tokens.filter(token => token.type === 'math_block')
console.log(`\nMath blocks found: ${mathBlocks.length}`)
mathBlocks.forEach((block, i) => {
console.log(` Block ${i + 1}: content="${block.content}" markup="${block.markup}"`)
})
// 应该识别为 math_block
expect(mathBlocks.length).toBeGreaterThanOrEqual(1)
expect(mathBlocks[0].content).toContain('f _ { t r a n s f e r }')
})
it('should parse the spaced subscript math with parseMarkdownToStructure', () => {
const md = getMarkdown('test')
const markdown = `$$
f _ { t r a n s f e r } : M _ { s } M _ { l }
$$`
const nodes = parseMarkdownToStructure(markdown, md)
console.log('=== Nodes ===')
nodes.forEach((node, i) => {
console.log(` ${i}: type=${node.type} content="${String((node as any).content || '').slice(0, 60)}"`)
})
// 检查是否有 math_block 节点
const mathBlocks = nodes.filter(node => node.type === 'math_block')
console.log(`\nMath block nodes found: ${mathBlocks.length}`)
mathBlocks.forEach((block, i) => {
console.log(` Block ${i + 1}: content="${String((block as any).content)}"`)
})
// 应该识别为 math_block 节点
expect(mathBlocks.length).toBeGreaterThanOrEqual(1)
expect(String((mathBlocks[0] as any).content)).toContain('f _ { t r a n s f e r }')
})
})
@@ -0,0 +1,54 @@
import { mount } from '@vue/test-utils'
import katex from 'katex'
import { describe, expect, it, vi } from 'vitest'
import MathBlockNode from '../src/components/MathBlockNode/MathBlockNode.vue'
import { flushAll } from './setup/flush-all'
vi.mock('../src/components/MathInlineNode/katex', () => ({
getKatexSync: () => null,
getKatex: async () => ({
renderToString: (content: string, opts: any) => katex.renderToString(content, {
...opts,
output: 'html',
strict: 'ignore',
}),
}),
}))
vi.mock('../src/workers/katexWorkerClient', async () => {
const actual: any = await vi.importActual('../src/workers/katexWorkerClient')
return {
...actual,
renderKaTeXWithBackpressure: async (content: string, displayMode = true) => katex.renderToString(content, {
throwOnError: true,
displayMode,
output: 'html',
strict: 'ignore',
}),
setKaTeXCache: vi.fn(),
}
})
describe('mathBlockNode unicode unit render regression', () => {
it('normalizes unsupported unit glyphs before worker rendering', async () => {
const wrapper = mount(MathBlockNode as any, {
props: {
node: {
type: 'math_block',
content: 'Q_1=0.75\\times10^3\\ \\text{J/(kg·℃)}\\times1.1\\ \\text{kg}\\times40℃=3.3\\times 10^{4}\\ \\text{J}',
raw: '$$Q_1=0.75\\times10^3\\ \\text{J/(kg·℃)}\\times1.1\\ \\text{kg}\\times40℃=3.3\\times 10^{4}\\ \\text{J}$$',
markup: '$$',
loading: false,
},
},
})
await flushAll()
const html = wrapper.html()
expect(html).toContain('data-markstream-mode="katex"')
expect(html).toContain('class="katex"')
expect(html).not.toContain('math-block__fallback')
expect(html).not.toContain('$$Q_1=0.75\\times10^3\\ \\text{J/(kg·℃)}\\times1.1\\ \\text{kg}\\times40℃=3.3\\times 10^{4}\\ \\text{J}$$')
})
})
@@ -0,0 +1,34 @@
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
import { describe, expect, it } from 'vitest'
describe('math plugin - bracket delimiter false positives', () => {
it('should not treat JSON arrays as bracket-math blocks', () => {
const md = getMarkdown('json-array')
const markdown = `
[
{ "a": 1 },
{ "b": 2 }
]
`
const tokens = md.parse(markdown, { __markstreamFinal: true }) as any[]
expect(tokens.some(t => t?.type === 'math_block')).toBe(false)
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
expect(nodes.some(n => n.type === 'math_block')).toBe(false)
})
it('should not treat numeric arrays as bracket-math blocks', () => {
const md = getMarkdown('number-array')
const markdown = `
[
1,
2
]
`
const tokens = md.parse(markdown, { __markstreamFinal: true }) as any[]
expect(tokens.some(t => t?.type === 'math_block')).toBe(false)
const nodes = parseMarkdownToStructure(markdown, md, { final: true })
expect(nodes.some(n => n.type === 'math_block')).toBe(false)
})
})
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest'
import { getMarkdown, parseMarkdownToStructure } from '../packages/markdown-parser/src'
describe('math complex mixed $ and $$ delimiters', () => {
it('should parse alternating $ and $$ delimiters correctly', () => {
const md = getMarkdown('test-complex-mixed')
const result = parseMarkdownToStructure('数学:$E=mc^2$ 和 $$E=mc^2$$ 和 $E=mc^2$ 和 $$E=mc^2$$', md)
console.log('Full Result:', JSON.stringify(result, null, 2))
const paragraph = result[0]
expect(paragraph?.type).toBe('paragraph')
const mathNodes = paragraph?.children?.filter(c => c.type === 'math_inline')
console.log('Math nodes:', JSON.stringify(mathNodes, null, 2))
console.log('All children:', JSON.stringify(paragraph?.children, null, 2))
expect(mathNodes).toHaveLength(4)
// Alternating delimiters should all be recognized
expect(mathNodes?.[0]?.content).toBe('E=mc^2')
expect(mathNodes?.[0]?.markup).toBe('$')
expect(mathNodes?.[1]?.content).toBe('E=mc^2')
expect(mathNodes?.[1]?.markup).toBe('$$')
expect(mathNodes?.[2]?.content).toBe('E=mc^2')
expect(mathNodes?.[2]?.markup).toBe('$')
expect(mathNodes?.[3]?.content).toBe('E=mc^2')
expect(mathNodes?.[3]?.markup).toBe('$$')
})
it('should parse $$ followed by $ correctly', () => {
const md = getMarkdown('test-complex-mixed-2')
const result = parseMarkdownToStructure('数学:$$E=mc^2$$ 和 $E=mc^2$', md)
const paragraph = result[0]
expect(paragraph?.type).toBe('paragraph')
const mathNodes = paragraph?.children?.filter(c => c.type === 'math_inline')
// Should have 2 math_inline nodes
expect(mathNodes).toHaveLength(2)
// First math: $$E=mc^2$$
expect(mathNodes?.[0]?.content).toBe('E=mc^2')
expect(mathNodes?.[0]?.markup).toBe('$$')
// Second math: $E=mc^2$
expect(mathNodes?.[1]?.content).toBe('E=mc^2')
expect(mathNodes?.[1]?.markup).toBe('$')
})
// Known limitation: When $ comes before $$, the $ gets pushed as text
// because the $$ rule runs first and processes everything before $$
// This would require significant refactoring to fix
it('should parse $ followed by $$ correctly', () => {
const md = getMarkdown('test-complex-mixed-3')
const result = parseMarkdownToStructure('数学:$E=mc^2$ 和 $$E=mc^2$$', md)
const paragraph = result[0]
expect(paragraph?.type).toBe('paragraph')
const mathNodes = paragraph?.children?.filter(c => c.type === 'math_inline')
// Should have 2 math_inline nodes
expect(mathNodes).toHaveLength(2)
// First math: $E=mc^2$
expect(mathNodes?.[0]?.content).toBe('E=mc^2')
expect(mathNodes?.[0]?.markup).toBe('$')
// Second math: $$E=mc^2$$
expect(mathNodes?.[1]?.content).toBe('E=mc^2')
expect(mathNodes?.[1]?.markup).toBe('$$')
})
})
@@ -0,0 +1,64 @@
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
import { describe, expect, it } from 'vitest'
function walk(value: unknown, visit: (node: any) => void, seen = new WeakSet<object>()) {
if (!value || typeof value !== 'object')
return
if (seen.has(value))
return
seen.add(value)
if (Array.isArray(value)) {
for (const item of value)
walk(item, visit, seen)
return
}
const node = value as any
if (typeof node.type === 'string')
visit(node)
for (const child of Object.values(node))
walk(child, visit, seen)
}
function collectByType(nodes: unknown, type: string) {
const out: any[] = []
walk(nodes, (node) => {
if (node.type === type)
out.push(node)
})
return out
}
describe('math inline - currency dollars should not be parsed as math', () => {
const md = getMarkdown('math-currency-dollar-regression')
const input = `**$2000~$5000美元**
等价于 $...$,但更推荐在现代 LaTeX 编译器中使用 \`$...$\` 与 \`\\(...\\)\` 以提高可读性和安全性。`
it('keeps currency and placeholder dollars as text; only backticked variants are code', () => {
const nodes = parseMarkdownToStructure(input, md, { final: true })
const paragraphs = nodes.filter((n: any) => n?.type === 'paragraph') as any[]
expect(paragraphs.length).toBeGreaterThanOrEqual(2)
const strong = (paragraphs[0]?.children ?? []).find((c: any) => c?.type === 'strong')
expect(strong).toBeDefined()
expect(JSON.stringify(strong)).toContain('$2000~$5000美元')
expect(collectByType(strong, 'math_inline')).toHaveLength(0)
const p2Math = collectByType(paragraphs[1], 'math_inline')
expect(p2Math).toHaveLength(0)
const p2Text = JSON.stringify(collectByType(paragraphs[1], 'text'))
expect(p2Text.includes('$...$') || p2Text.includes('$…$')).toBe(true)
const p2Codes = collectByType(paragraphs[1], 'inline_code')
expect(p2Codes).toHaveLength(2)
expect(String(p2Codes[0]?.code ?? '')).toBe('$...$')
const code2 = String(p2Codes[1]?.code ?? '')
expect(code2.startsWith('\\(')).toBe(true)
expect(code2.endsWith('\\)')).toBe(true)
})
})

Some files were not shown because too many files have changed in this diff Show More