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:
@@ -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) => 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&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: '',
|
||||
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: '\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
|
||||
|
||||

|
||||
|
||||
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  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: `[](https://www.npmjs.com/package/markstream-vue)
|
||||
[](https://markstream-vue.simonhe.me/)
|
||||
[](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: `[](https://www.npmjs.com/package/markstream-vue)
|
||||
[](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.
|
||||
:::
|
||||
@@ -0,0 +1,4 @@
|
||||
- [x] Completed task
|
||||
- [ ] Incomplete task
|
||||
|
||||
Regular list item
|
||||
@@ -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.
|
||||
@@ -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: 
|
||||
|
||||
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 完成
|
||||
```
|
||||
@@ -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)
|
||||
@@ -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
|
||||
+62
@@ -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.
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import MarkdownRender from 'markstream-react'
|
||||
import 'markstream-react/index.css'
|
||||
|
||||
export function BasicArticle({ markdown }: { markdown: string }) {
|
||||
return <MarkdownRender content={markdown} />
|
||||
}
|
||||
Vendored
+71
@@ -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>
|
||||
)
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import Markdown from 'react-markdown'
|
||||
|
||||
export function BasicArticle({ markdown }: { markdown: string }) {
|
||||
return <Markdown>{markdown}</Markdown>
|
||||
}
|
||||
+53
@@ -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.
|
||||
@@ -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: '',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
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: '',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
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>
|
||||
|
||||
✖ 该机构没有关联机构数据匹配,无法进行机构画像生成。
|
||||
`
|
||||
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('✖ 该机构没有关联机构数据匹配,无法进行机构画像生成。')
|
||||
})
|
||||
|
||||
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 = [
|
||||
` `,
|
||||
` &`,
|
||||
` © 2024`,
|
||||
` ✖ Error message`,
|
||||
` ✔ Success`,
|
||||
` <tag>`,
|
||||
` "quoted"`,
|
||||
` — dash`,
|
||||
` 〹`,
|
||||
` ∅`,
|
||||
]
|
||||
|
||||
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 = '[](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 & 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 '&'
|
||||
expect(String(text)).toMatch(/&|&/)
|
||||
})
|
||||
|
||||
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 = '**[](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 = ''
|
||||
|
||||
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('\\
|
||||
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 = '[
|
||||
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 = '['
|
||||
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 = '[]('
|
||||
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 ['
|
||||
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 [
|
||||
'\\[',
|
||||
'\\[](',
|
||||
'\\[](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 \\[
|
||||
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 = '\\\\[
|
||||
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 = '[](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: '',
|
||||
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...
|
||||
✖ Error occurred
|
||||
</thinking>
|
||||
|
||||
✔ 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('✔')
|
||||
})
|
||||
|
||||
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>
|
||||
✖ Error
|
||||
</inner>
|
||||
</outer>
|
||||
|
||||
✔ 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('✔')
|
||||
})
|
||||
})
|
||||
|
||||
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>
|
||||
|
||||
✖ 该机构没有关联机构数据匹配,无法进行机构画像生成。
|
||||
`
|
||||
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('✖')
|
||||
})
|
||||
|
||||
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('" => {
|
||||
const nodes = parseMarkdownToStructure('
|
||||
// Depending on tokenizer, an image may already be emitted or fallback to text
|
||||
const images = collect(nodes, 'image')
|
||||
expect(images.length >= 0).toBe(true)
|
||||
expect(images.length > 0 || textIncludes(nodes, ').toBe(true)
|
||||
})
|
||||
|
||||
it('"" becomes an image node', () => {
|
||||
const nodes = parseMarkdownToStructure('', md)
|
||||
const images = collect(nodes, 'image') as any[]
|
||||
expect(images.length).toBe(1)
|
||||
expect(images[0].src || images[0].href).toBe('http://img')
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkbox list item transitions', () => {
|
||||
it('"- [" is a mid-state and should not crash', () => {
|
||||
const nodes = parseMarkdownToStructure('- [', md)
|
||||
// Accept paragraph or list fallback safely
|
||||
const ok = hasParagraph(nodes) || hasNode(nodes, 'list')
|
||||
expect(ok).toBe(true)
|
||||
})
|
||||
|
||||
it('"- [ ] x" becomes an unchecked checkbox item when newline present', () => {
|
||||
const nodes = parseMarkdownToStructure('- [ ] x\n', md)
|
||||
const list = collect(nodes, 'list')[0] as any
|
||||
expect(list).toBeTruthy()
|
||||
const item = list.items?.[0]
|
||||
const para = item?.children?.find((c: any) => c.type === 'paragraph')
|
||||
expect(!!para).toBe(true)
|
||||
// checkbox node is produced as an inline child of the paragraph
|
||||
expect(paragraphHasCheckbox([item])).toBe(true)
|
||||
const text = para?.children?.find((c: any) => c.type === 'text')
|
||||
expect(text?.content || '').toContain('x')
|
||||
})
|
||||
|
||||
it('"- [x] x" becomes a checked checkbox item', () => {
|
||||
const nodes = parseMarkdownToStructure('- [x] hello\n', md)
|
||||
const list = collect(nodes, 'list')[0] as any
|
||||
expect(list).toBeTruthy()
|
||||
const item = list.items?.[0]
|
||||
const para = item?.children?.find((c: any) => c.type === 'paragraph')
|
||||
expect(!!para).toBe(true)
|
||||
expect(paragraphHasCheckbox([item])).toBe(true)
|
||||
// checked could be on either checkbox or checkbox_input node
|
||||
const checkbox = paragraphFirst([item])?.children?.find((c: any) => c.type === 'checkbox')
|
||||
|| paragraphFirst([item])?.children?.find((c: any) => c.type === 'checkbox_input')
|
||||
const checked = checkbox?.checked ?? checkbox?.attrs?.checked
|
||||
expect(checked).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('thematic break transitions', () => {
|
||||
it('"-" and "--" are not hr; "---" is hr', () => {
|
||||
const a = parseMarkdownToStructure('-', md)
|
||||
expect(collect(a, 'thematic_break').length).toBe(0)
|
||||
const b = parseMarkdownToStructure('--', md)
|
||||
expect(collect(b, 'thematic_break').length).toBe(0)
|
||||
const c = parseMarkdownToStructure('---', md)
|
||||
expect(collect(c, 'thematic_break').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hardbreak transitions', () => {
|
||||
it('two spaces before newline produces a hardbreak; one space does not', () => {
|
||||
const no = parseMarkdownToStructure('line \nnext', md)
|
||||
expect(collect(no, 'hardbreak').length).toBe(0)
|
||||
const yes = parseMarkdownToStructure('line \nnext', md)
|
||||
expect(collect(yes, 'hardbreak').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('footnote reference mid-states', () => {
|
||||
it('"[^1" remains text; "[^1]" may become a footnote_reference depending on plugins', () => {
|
||||
const a = parseMarkdownToStructure('[^1', md)
|
||||
expect(collect(a, 'footnote_reference').length).toBe(0)
|
||||
const b = parseMarkdownToStructure('[^1]', md)
|
||||
// token type may be 'footnote_reference' or fall back to text if plugin config defers
|
||||
const got = collect(b, 'footnote_reference')
|
||||
if (got.length === 0) {
|
||||
expect(textIncludes(b, '[^1]')).toBe(true)
|
||||
}
|
||||
else {
|
||||
expect(got.length).toBeGreaterThanOrEqual(1)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('reference-style link mid-states', () => {
|
||||
it('"[text][id" stays as text/reference token; "[text][id]" recognized as reference', () => {
|
||||
const a = parseMarkdownToStructure('[text][id', md)
|
||||
// May yield a 'reference' inline token fallback or plain text
|
||||
const okA = collect(a, 'reference').length >= 0
|
||||
expect(okA).toBe(true)
|
||||
const b = parseMarkdownToStructure('[text][id]', md)
|
||||
// Depending on plugin setup, may still be a reference token awaiting definition
|
||||
expect(collect(b, 'reference').length >= 0).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('numeric reference mid-states', () => {
|
||||
it('keeps completed numeric references stable while the next numeric reference is still incomplete', () => {
|
||||
const mid = parseMarkdownToStructure('引用[1][2', md)
|
||||
const midRefs = collect(mid, 'reference') as any[]
|
||||
expect(midRefs).toHaveLength(1)
|
||||
expect(midRefs[0]?.id).toBe('1')
|
||||
expect(textIncludes(mid, '[2')).toBe(true)
|
||||
|
||||
const full = parseMarkdownToStructure('引用[1][2]', md)
|
||||
const fullRefs = collect(full, 'reference') as any[]
|
||||
expect(fullRefs.map(ref => ref.id)).toEqual(['1', '2'])
|
||||
})
|
||||
|
||||
it('keeps the numeric reference when a following inline link has only opened its href', () => {
|
||||
const nodes = parseMarkdownToStructure('引用 [1][百度](', md)
|
||||
const refs = collect(nodes, 'reference') as any[]
|
||||
expect(refs).toHaveLength(1)
|
||||
expect(refs[0]?.id).toBe('1')
|
||||
expect(textIncludes(nodes, '百度')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fenced code block mid-states', () => {
|
||||
it('"```" or "```js" mid-state: accept fence node or paragraph fallback', () => {
|
||||
for (const input of ['```', '```js', '```js\nconsole.log(1)']) {
|
||||
const nodes = parseMarkdownToStructure(input, md)
|
||||
expect(fenceOrParagraph(nodes)).toBe(true)
|
||||
}
|
||||
})
|
||||
it('mermaid fence: unclosed vs closed', () => {
|
||||
const unclosed = parseMarkdownToStructure('```mermaid\nflowchart TD\nA-->B', md)
|
||||
const f1 = collect(unclosed, 'code_block') as any[]
|
||||
if (f1.length > 0) {
|
||||
// If recognized early, language should be mermaid and likely loading
|
||||
expect(f1[0].language).toBe('mermaid')
|
||||
// loading may be true for unclosed fences
|
||||
expect([true, false]).toContain(!!f1[0].loading)
|
||||
}
|
||||
else {
|
||||
// Accept paragraph fallback
|
||||
expect(hasParagraph(unclosed)).toBe(true)
|
||||
}
|
||||
|
||||
const closed = parseMarkdownToStructure('```mermaid\nflowchart TD\nA-->B\n```', md)
|
||||
const f2 = collect(closed, 'code_block') as any[]
|
||||
expect(f2.length).toBeGreaterThanOrEqual(1)
|
||||
expect(f2[0].language).toBe('mermaid')
|
||||
// closed fences should be loading=false when mapping closed is detected
|
||||
if (typeof f2[0].loading === 'boolean')
|
||||
expect(f2[0].loading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('table transitions', () => {
|
||||
it('"a | b" alone is not a table; add separator to form a table', () => {
|
||||
const a = parseMarkdownToStructure('a | b', md)
|
||||
expect(collect(a, 'table').length).toBe(0)
|
||||
const b = parseMarkdownToStructure('a | b\n---|---', md)
|
||||
expect(collect(b, 'table').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
it('alignment separators (:\n---: etc.) should not be parsed as emoji', () => {
|
||||
const s = parseMarkdownToStructure('A | B\n:---:|---', md)
|
||||
const table = collect(s, 'table')[0]
|
||||
expect(!!table).toBe(true)
|
||||
// Ensure no stray emoji nodes in header/separator processing
|
||||
const emojis = collect(s, 'emoji')
|
||||
expect(emojis.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('marking mid-states (strikethrough/highlight/insert/sub/sup)', () => {
|
||||
it('strikethrough: "~~x" or "~~x~~" should yield a strikethrough node', () => {
|
||||
for (const input of ['~~x', '~~x~~']) {
|
||||
const nodes = parseMarkdownToStructure(input, md)
|
||||
const s = collect(nodes, 'strikethrough')
|
||||
expect(s.length).toBeGreaterThanOrEqual(1)
|
||||
}
|
||||
})
|
||||
it('highlight: "==x" may stay text until closed; "==x==" highlights', () => {
|
||||
const a = parseMarkdownToStructure('==x', md)
|
||||
expect(collect(a, 'highlight').length).toBe(0)
|
||||
const b = parseMarkdownToStructure('==x==', md)
|
||||
expect(collect(b, 'highlight').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
it('insert/underline: "++x" may stay text; "++x++" inserts', () => {
|
||||
const a = parseMarkdownToStructure('++x', md)
|
||||
expect(collect(a, 'insert').length).toBe(0)
|
||||
const b = parseMarkdownToStructure('++x++', md)
|
||||
expect(collect(b, 'insert').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
it('superscript and subscript mid-states', () => {
|
||||
const supA = parseMarkdownToStructure('^2', md)
|
||||
expect(collect(supA, 'superscript').length).toBe(0)
|
||||
const supB = parseMarkdownToStructure('^2^', md)
|
||||
expect(collect(supB, 'superscript').length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
const subA = parseMarkdownToStructure('~2', md)
|
||||
expect(collect(subA, 'subscript').length).toBe(0)
|
||||
const subB = parseMarkdownToStructure('~2~', md)
|
||||
expect(collect(subB, 'subscript').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('emoji with links/lists mix edge cases', () => {
|
||||
it('list item mixing emoji and link (mid-state link)', () => {
|
||||
const nodes = parseMarkdownToStructure('- :smile: [Go](http://a', md)
|
||||
const list = collect(nodes, 'list')[0] as any
|
||||
expect(!!list).toBe(true)
|
||||
const para = list.items?.[0]?.children?.find((c: any) => c.type === 'paragraph')
|
||||
expect(!!para).toBe(true)
|
||||
const hasEmojiInPara = hasEmoji(para?.children || [])
|
||||
const links = para ? para.children.filter((c: any) => c.type === 'link') : []
|
||||
// Link may be recognized as loading link at this stage
|
||||
const ok = hasEmojiInPara && (links.length >= 0)
|
||||
expect(ok).toBe(true)
|
||||
})
|
||||
it('link text may contain emoji shortcode', () => {
|
||||
const nodes = parseMarkdownToStructure('[:smile:](http://a)', md)
|
||||
const ls = links(nodes)
|
||||
expect(ls.length).toBe(1)
|
||||
// Either emoji child in link, or raw/text contains the shortcode or the emoji char
|
||||
expect(hasEmoji(ls[0].children || []) || textIncludes(ls[0], ':smile:') || textIncludes(ls[0], '😄')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('extra edge cases (tolerant mid-states)', () => {
|
||||
describe('table trailing pipes and escaped pipes', () => {
|
||||
it('"a | b |" alone is not a table; add separator with trailing pipes to form a table', () => {
|
||||
const a = parseMarkdownToStructure('a | b |', md)
|
||||
expect(collect(a, 'table').length).toBe(0)
|
||||
const b = parseMarkdownToStructure('a | b |\n---|---|', md)
|
||||
expect(collect(b, 'table').length > 0).toBe(true)
|
||||
})
|
||||
|
||||
it('escaped pipes do not form a table until a valid separator appears', () => {
|
||||
const s1 = parseMarkdownToStructure('a \\| b', md)
|
||||
expect(collect(s1, 'table').length).toBe(0)
|
||||
const p = collect(s1, 'paragraph')[0] as any
|
||||
// Robustly assert paragraph text contains the expected pieces (allowing parser to keep or remove escape)
|
||||
// Prefer the helper which serializes nodes and searches for the text pieces
|
||||
expect((typeof p !== 'undefined' ? textIncludes(p, 'a') : textIncludes(s1, 'a')) && (typeof p !== 'undefined' ? textIncludes(p, 'b') : textIncludes(s1, 'b'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fenced code blocks with params and blank content', () => {
|
||||
it('```js { foo:1 } mid-state: accept fence node (language starts with js) or paragraph fallback', () => {
|
||||
const nodes = parseMarkdownToStructure('```js { foo:1 }', md)
|
||||
expect(fenceOrParagraph(nodes)).toBe(true)
|
||||
const fenceNodes = collect(nodes, 'code_block') as any[]
|
||||
const fenceAlt = collect(nodes, 'fence') as any[]
|
||||
const hasFence = fenceNodes.length > 0 || fenceAlt.length > 0
|
||||
if (hasFence) {
|
||||
const f = (fenceNodes[0] || fenceAlt[0]) as any
|
||||
// language should be parsed as js when recognized
|
||||
if (typeof f?.language === 'string')
|
||||
expect(f.language.startsWith('js')).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('``` followed by newline with empty content is a valid mid-state', () => {
|
||||
const nodes = parseMarkdownToStructure('```\n', md)
|
||||
expect(fenceOrParagraph(nodes)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('link + emoji variants', () => {
|
||||
it('emoji at end of link text with URL unclosed yields loading link or text fallback', () => {
|
||||
const nodes = parseMarkdownToStructure('[:smile:x](http://a', md)
|
||||
if (hasLoadingLink(nodes)) {
|
||||
const ls = links(nodes)
|
||||
const l = ls[0]
|
||||
expect(!!l.loading).toBe(true)
|
||||
expect(textIncludes(l, ':smile:x') || textIncludes(l, 'emoji')).toBe(true)
|
||||
}
|
||||
else {
|
||||
expect(textIncludes(nodes, '[:smile:x](http://a') || textIncludes(nodes, 'emoji')).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('list item with multiple emoji and unclosed link remains a list with paragraph child', () => {
|
||||
const nodes = parseMarkdownToStructure('- :rocket: :fire: [Go](http://a', md)
|
||||
const list = collect(nodes, 'list')[0] as any
|
||||
expect(!!list).toBe(true)
|
||||
const para = list.items?.[0]?.children?.find((c: any) => c.type === 'paragraph')
|
||||
expect(!!para).toBe(true)
|
||||
expect(hasEmoji(para?.children || [])).toBe(true)
|
||||
// link may or may not be recognized yet, but should not crash
|
||||
const links = para ? para.children.filter((c: any) => c.type === 'link') : []
|
||||
expect(links.length >= 0).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('interleaved marking mid-states', () => {
|
||||
it('"~~**x" mid-state: tolerate as text or as partially parsed marking without crash', () => {
|
||||
const nodes = parseMarkdownToStructure('~~**x', md)
|
||||
const ok = textIncludes(nodes, '~~**x')
|
||||
|| collect(nodes, 'strikethrough').length >= 1
|
||||
|| collect(nodes, 'strong').length >= 1
|
||||
expect(ok).toBe(true)
|
||||
})
|
||||
|
||||
it('"**~~x" mid-state: tolerate as text or as partially parsed marking without crash', () => {
|
||||
const nodes = parseMarkdownToStructure('**~~x', md)
|
||||
const ok = textIncludes(nodes, '**~~x')
|
||||
|| collect(nodes, 'strikethrough').length >= 1
|
||||
|| collect(nodes, 'strong').length >= 1
|
||||
expect(ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('blockquote/list/checkbox combined mid-state', () => {
|
||||
it('"> - [ ] x" without newline: accept blockquote or paragraph/list fallback', () => {
|
||||
const nodes = parseMarkdownToStructure('> - [ ] x', md)
|
||||
const ok = hasNode(nodes, 'blockquote')
|
||||
|| hasParagraph(nodes)
|
||||
|| hasNode(nodes, 'list')
|
||||
expect(ok).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('more extras (tolerant mid-states v2)', () => {
|
||||
describe('autolink angle-bracket mid-states', () => {
|
||||
it('"<http://a" mid-state: allow plain text or early link; no crash', () => {
|
||||
const nodes = parseMarkdownToStructure('<http://a', md)
|
||||
// Either no link yet or tokenizer emits a provisional link
|
||||
const links = collect(nodes, 'link')
|
||||
expect(links.length >= 0).toBe(true)
|
||||
})
|
||||
|
||||
it('"<http://a>" finalized autolink: may become a link node or remain text depending on plugins', () => {
|
||||
const nodes = parseMarkdownToStructure('<http://a>', md)
|
||||
const links = collect(nodes, 'link') as any[]
|
||||
if (links.length > 0) {
|
||||
// tolerant check for href/url on autolink depending on plugin
|
||||
expect(textIncludes(links[0], 'http://a') || String(links[0].href || links[0].url || '').includes('http://a')).toBe(true)
|
||||
}
|
||||
else {
|
||||
// Accept tokenizer keeping as plain text when autolink plugin is not enabled
|
||||
expect(textIncludes(nodes, '<http://a>')).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('tilde fenced code blocks', () => {
|
||||
it('"~~~" mid-state: accept fence or paragraph fallback', () => {
|
||||
const nodes = parseMarkdownToStructure('~~~', md)
|
||||
const fence = collect(nodes, 'code_block').length > 0
|
||||
|| collect(nodes, 'fence').length > 0
|
||||
const para = hasParagraph(nodes)
|
||||
expect(fence || para).toBe(true)
|
||||
})
|
||||
|
||||
it('closed tilde fence recognized as code block', () => {
|
||||
const nodes = parseMarkdownToStructure('~~~\ncode\n~~~', md)
|
||||
const code = collect(nodes, 'code_block').length > 0
|
||||
|| collect(nodes, 'fence').length > 0
|
||||
expect(code).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('indented code block mid-states', () => {
|
||||
it('3 spaces then text is not an indented code block', () => {
|
||||
const nodes = parseMarkdownToStructure(' not-code', md)
|
||||
const isCode = collect(nodes, 'code_block').length > 0
|
||||
expect(isCode).toBe(false)
|
||||
expect(hasParagraph(nodes)).toBe(true)
|
||||
})
|
||||
|
||||
it('4 spaces then text becomes an indented code block (or fence-like)', () => {
|
||||
const nodes = parseMarkdownToStructure(' code', md)
|
||||
const isCode = collect(nodes, 'code_block').length > 0
|
||||
|| collect(nodes, 'fence').length > 0
|
||||
const para = hasParagraph(nodes)
|
||||
expect(isCode || para).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('link title mid-states', () => {
|
||||
it('"[x](http://a \"t" mid-state: allow loading link or text fallback', () => {
|
||||
const nodes = parseMarkdownToStructure('[x](http://a "t', md)
|
||||
const links = collect(nodes, 'link') as any[]
|
||||
if (links.length > 0) {
|
||||
const l = links[0]
|
||||
// may not set loading for title mid-state; just ensure no crash
|
||||
expect(!!l).toBe(true)
|
||||
}
|
||||
else {
|
||||
expect(textIncludes(nodes, '[x](http://a "t')).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('"[x](http://a \"t\")" finalized link; title may be present', () => {
|
||||
const nodes = parseMarkdownToStructure('[x](http://a "t")', md)
|
||||
const links = collect(nodes, 'link') as any[]
|
||||
expect(links.length).toBe(1)
|
||||
expect(links[0].href).toBe('http://a')
|
||||
// title is optional based on tokenizer; accept either
|
||||
if (typeof links[0].title === 'string')
|
||||
expect(links[0].title).toBe('t')
|
||||
})
|
||||
})
|
||||
|
||||
describe('image alt containing emoji shortcode', () => {
|
||||
it('" => {
|
||||
const nodes = parseMarkdownToStructure('
|
||||
const images = collect(nodes, 'image')
|
||||
expect(images.length >= 0).toBe(true)
|
||||
expect(images.length > 0 || textIncludes(nodes, ').toBe(true)
|
||||
})
|
||||
|
||||
it('"" finalized image; alt may include emoji or raw text', () => {
|
||||
const nodes = parseMarkdownToStructure('', md)
|
||||
const images = collect(nodes, 'image') as any[]
|
||||
expect(images.length).toBe(1)
|
||||
expect(images[0].src || images[0].href).toBe('http://img')
|
||||
expect(textIncludes(images[0], 'emoji') || textIncludes(images[0], ':smile:')).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('more extras (tolerant mid-states v3)', () => {
|
||||
describe('reference definition mid-states', () => {
|
||||
it('"[id]:" mid-state: stays text or provisional reference_def; no crash', () => {
|
||||
const nodes = parseMarkdownToStructure('[id]:', md)
|
||||
// Accept as plain text or any reference-like node
|
||||
const ok = textIncludes(nodes, '[id]:') || collect(nodes, 'reference_def').length >= 0
|
||||
expect(ok).toBe(true)
|
||||
})
|
||||
|
||||
it('"[id]: http://a" may become a reference definition or remain text', () => {
|
||||
const nodes = parseMarkdownToStructure('[id]: http://a', md)
|
||||
const defs = collect(nodes, 'reference_def') as any[]
|
||||
if (defs.length > 0) {
|
||||
// href/src naming may differ
|
||||
const href = defs[0].href || defs[0].url || defs[0].src
|
||||
// Prefer tolerant structural check; fall back to string include when needed
|
||||
expect(textIncludes(defs[0], 'http://a') || String(href || '').includes('http://a')).toBe(true)
|
||||
}
|
||||
else {
|
||||
const hasPara = collect(nodes, 'paragraph').length > 0
|
||||
// Accept normalized text or even empty/no-op output depending on plugins
|
||||
const ok = hasPara || textIncludes(nodes, 'http://a') || textIncludes(nodes, '[id]') || Array.isArray(nodes)
|
||||
expect(ok).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('use-before-def mid-state: "[x][id]\n[id]:" tolerates as text or partial link', () => {
|
||||
const nodes = parseMarkdownToStructure('[x][id]\n[id]:', md)
|
||||
const links = collect(nodes, 'link')
|
||||
const texts = collect(nodes, 'text')
|
||||
// Either link is recognized or kept as text until def completes
|
||||
expect(links.length >= 0).toBe(true)
|
||||
expect(texts.length === 1 || textIncludes(nodes, '[x][id]\n[id]:')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('custom container/admonition mid-states', () => {
|
||||
it('"::: tip" mid-state: accept container/admonition node or paragraph fallback', () => {
|
||||
const nodes = parseMarkdownToStructure('::: tip', md)
|
||||
const container = collect(nodes, 'container')[0] || collect(nodes, 'admonition')[0]
|
||||
const para = hasParagraph(nodes)
|
||||
expect(!!container || para).toBe(true)
|
||||
})
|
||||
|
||||
it('"::: tip\ncontent\n:::" closed: container/admonition with paragraph child or paragraph fallback', () => {
|
||||
const nodes = parseMarkdownToStructure('::: tip\ncontent\n:::', md)
|
||||
const container = (collect(nodes, 'container')[0] || collect(nodes, 'admonition')[0]) as any
|
||||
if (container) {
|
||||
const hasPara = (container.children || container.items || []).some((c: any) => c?.type === 'paragraph')
|
||||
expect(hasPara).toBe(true)
|
||||
}
|
||||
else {
|
||||
// Accept fallback
|
||||
expect(hasParagraph(nodes)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('hTML block mid-states', () => {
|
||||
it('"<div" mid-state: accept html_block or paragraph fallback', () => {
|
||||
const nodes = parseMarkdownToStructure('<div', md)
|
||||
const html = collect(nodes, 'html_block').length > 0 || collect(nodes, 'html').length > 0
|
||||
const para = collect(nodes, 'paragraph').length > 0
|
||||
expect(html || para || Array.isArray(nodes)).toBe(true)
|
||||
})
|
||||
|
||||
it('"<div>\ntext\n</div>" closed: may be html_block or paragraphs', () => {
|
||||
const nodes = parseMarkdownToStructure('<div>\ntext\n</div>', md)
|
||||
const html = collect(nodes, 'html_block').length > 0 || collect(nodes, 'html').length > 0
|
||||
const para = collect(nodes, 'paragraph').length > 0
|
||||
expect(html || para || Array.isArray(nodes)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('math block mid-states', () => {
|
||||
it('"$$" mid-state: accept math_block or paragraph fallback', () => {
|
||||
const nodes = parseMarkdownToStructure('$$', md)
|
||||
const math = collect(nodes, 'math_block').length > 0 || collect(nodes, 'math').length > 0
|
||||
const para = hasParagraph(nodes)
|
||||
expect(math || para).toBe(true)
|
||||
})
|
||||
|
||||
it('"$$\na+b\n$$" closed: should be math_block or paragraph fallback (depending on plugin)', () => {
|
||||
const nodes = parseMarkdownToStructure('$$\na+b\n$$', md)
|
||||
const math = collect(nodes, 'math_block').length > 0 || collect(nodes, 'math').length > 0
|
||||
const para = hasParagraph(nodes)
|
||||
expect(math || para).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('more extras (tolerant mid-states v4)', () => {
|
||||
describe('setext heading transitions', () => {
|
||||
it('"Title" stays paragraph; then "Title\n===" becomes heading level 1', () => {
|
||||
const a = parseMarkdownToStructure('Title', md)
|
||||
expect(hasNode(a, 'heading')).toBeFalsy()
|
||||
const b = parseMarkdownToStructure('Title\n===', md)
|
||||
const h = collect(b, 'heading')[0] as any
|
||||
expect(h).toBeTruthy()
|
||||
expect(h.level).toBe(1)
|
||||
})
|
||||
|
||||
it('"Title\n---" becomes heading level 2 (not hr here due to preceding text)', () => {
|
||||
const b = parseMarkdownToStructure('Title\n---', md)
|
||||
const h = collect(b, 'heading')[0] as any
|
||||
expect(h).toBeTruthy()
|
||||
expect(h.level).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('double backtick inline code mid-states', () => {
|
||||
it('"``x" yields inline_code or text fallback; "``x``" closes it', () => {
|
||||
const a = parseMarkdownToStructure('``x', md)
|
||||
const codeA = collect(a, 'inline_code')[0] as any
|
||||
const okA = !!codeA || textIncludes(a, '``x')
|
||||
expect(okA).toBe(true)
|
||||
|
||||
const b = parseMarkdownToStructure('``x``', md)
|
||||
const codeB = collect(b, 'inline_code')[0] as any
|
||||
if (codeB) {
|
||||
expect(codeB.code || codeB.raw || '').toContain('x')
|
||||
}
|
||||
else {
|
||||
// accept fallback if tokenizer chooses otherwise
|
||||
expect(textIncludes(b, '``x``')).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('escaped punctuation mid-states', () => {
|
||||
it('"\\*x" should not crash; parser may keep text or still parse emphasis depending on escapes', () => {
|
||||
const nodes = parseMarkdownToStructure('\\*x', md)
|
||||
const emph = collect(nodes, 'emphasis').length
|
||||
// Accept either: preserved literal *x in text, or emphasis recognized by tokenizer
|
||||
const ok = textIncludes(nodes, '*x') || emph >= 1
|
||||
expect(ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hTML comments mid-states', () => {
|
||||
it('"<!--" mid-state: accept html/html_block or paragraph fallback', () => {
|
||||
const a = parseMarkdownToStructure('<!--', md)
|
||||
const htmlish = collect(a, 'html_block').length > 0 || collect(a, 'html').length > 0
|
||||
const para = collect(a, 'paragraph').length > 0
|
||||
expect(htmlish || para || Array.isArray(a)).toBe(true)
|
||||
})
|
||||
|
||||
it('"<!-- c -->" closed: may be html/html_block or paragraph', () => {
|
||||
const b = parseMarkdownToStructure('<!-- c -->', md)
|
||||
const htmlish = collect(b, 'html_block').length > 0 || collect(b, 'html').length > 0
|
||||
const para = collect(b, 'paragraph').length > 0
|
||||
expect(htmlish || para || Array.isArray(b)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('footnote definition mid-states', () => {
|
||||
it('"[^1]:" mid-state remains text or provisional def node', () => {
|
||||
const a = parseMarkdownToStructure('[^1]:', md)
|
||||
const got = collect(a, 'footnote_definition')
|
||||
const para = collect(a, 'paragraph').length > 0
|
||||
expect(got.length >= 0).toBe(true)
|
||||
expect(got.length > 0 || para || textIncludes(a, '^1') || Array.isArray(a)).toBe(true)
|
||||
})
|
||||
|
||||
it('"[^1]: hello" may produce a definition or remain text', () => {
|
||||
const b = parseMarkdownToStructure('[^1]: hello', md)
|
||||
const got = collect(b, 'footnote_definition') as any[]
|
||||
if (got.length > 0) {
|
||||
expect(textIncludes(got[0], 'hello')).toBe(true)
|
||||
}
|
||||
else {
|
||||
const para = collect(b, 'paragraph').length > 0
|
||||
expect(para || textIncludes(b, 'hello') || textIncludes(b, '^1') || Array.isArray(b)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('definition list mid-states', () => {
|
||||
it('"Term\n:" mid-state tolerates as text or provisional definition list', () => {
|
||||
const a = parseMarkdownToStructure('Term\n:', md)
|
||||
const defs = collect(a, 'definition_list')
|
||||
.concat(collect(a, 'dl'))
|
||||
.concat(collect(a, 'definition'))
|
||||
expect(defs.length >= 0).toBe(true)
|
||||
expect(defs.length > 0 || textIncludes(a, 'Term') || textIncludes(a, '\n:')).toBe(true)
|
||||
})
|
||||
|
||||
it('"Term\n: def" becomes a definition list or acceptable fallback', () => {
|
||||
const b = parseMarkdownToStructure('Term\n: def', md)
|
||||
const defs = collect(b, 'definition_list')
|
||||
.concat(collect(b, 'dl'))
|
||||
.concat(collect(b, 'definition'))
|
||||
if (defs.length > 0) {
|
||||
expect(textIncludes(defs[0], 'def')).toBe(true)
|
||||
}
|
||||
else {
|
||||
// Accept paragraph/list fallback depending on tokenizer config
|
||||
const ok = hasParagraph(b) || hasNode(b, 'list')
|
||||
expect(ok).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('additional mid-state edge cases (tables/marking/mermaid/emoji-links)', () => {
|
||||
it('table cell containing inline code mid-state: should produce a table with inline_code in cell or fall back', () => {
|
||||
const nodes = parseMarkdownToStructure('a | `b`\n---|---', md)
|
||||
const tables = collect(nodes, 'table')
|
||||
if (tables.length > 0) {
|
||||
const tbl: any = tables[0]
|
||||
// Look for inline_code somewhere in the table structure
|
||||
const foundCode = textIncludes(tbl, '`b`') || textIncludes(tbl, 'b')
|
||||
expect(foundCode).toBe(true)
|
||||
}
|
||||
else {
|
||||
// Accept tolerant fallback
|
||||
expect(textIncludes(nodes, '`b`') || textIncludes(nodes, 'a')).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('highlight containing emphasis: "==*x*==" should tolerate nested parsing', () => {
|
||||
const nodes = parseMarkdownToStructure('==*x*==', md)
|
||||
// Accept either highlight node containing emphasis, or safe text fallback
|
||||
const hil = collect(nodes, 'highlight')
|
||||
const em = collect(nodes, 'emphasis')
|
||||
const ok = hil.length > 0 || em.length > 0 || textIncludes(nodes, '==*x*==')
|
||||
expect(ok).toBe(true)
|
||||
})
|
||||
|
||||
it('mermaid fence with leading spaces/unclosed should not crash', () => {
|
||||
const nodes = parseMarkdownToStructure(' ```mermaid\nflowchart LR\nA-->B', md)
|
||||
const code = collect(nodes, 'code_block')
|
||||
if (code.length > 0) {
|
||||
// language may be recognized despite leading spaces
|
||||
expect(textIncludes(code[0], 'mermaid') || textIncludes(code[0], 'flowchart')).toBe(true)
|
||||
}
|
||||
else {
|
||||
expect(textIncludes(nodes, 'flowchart') || hasParagraph(nodes)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('link title containing emoji mid-state: allow either loading link or text with emoji', () => {
|
||||
const nodes = parseMarkdownToStructure('[x](http://a "t😁"', md)
|
||||
const ls = links(nodes)
|
||||
if (ls.length > 0) {
|
||||
expect(!!ls[0].loading).toBe(true)
|
||||
// allow either the emoji present in raw/title or the 't' character (title stub)
|
||||
expect(
|
||||
hasEmoji(ls[0].children || [])
|
||||
|| textIncludes(ls[0], '😁')
|
||||
|| textIncludes(ls[0], 't')
|
||||
|| textIncludes(ls[0], 'smile')
|
||||
|| textIncludes(nodes, 't')
|
||||
|| textIncludes(nodes, '😁'),
|
||||
).toBe(true)
|
||||
}
|
||||
else {
|
||||
expect(textIncludes(nodes, 't😁') || textIncludes(nodes, '[x](http://a "t')).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('trailing pipe variants: "a | b |" with separator should form table with empty cell', () => {
|
||||
const nodes = parseMarkdownToStructure('a | b |\n---|---|', md)
|
||||
const tables = collect(nodes, 'table')
|
||||
if (tables.length > 0) {
|
||||
// table exists; ensure at least header cells are present
|
||||
expect(textIncludes(tables[0], 'a') && textIncludes(tables[0], 'b')).toBe(true)
|
||||
}
|
||||
else {
|
||||
expect(textIncludes(nodes, 'a') && textIncludes(nodes, 'b')).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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-->B')
|
||||
expect(previewHost?.innerHTML).not.toContain('B-->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('<span>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('<thinking>')
|
||||
})
|
||||
|
||||
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"')
|
||||
})
|
||||
})
|
||||
+76
@@ -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)
|
||||
})
|
||||
})
|
||||
+56
@@ -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: '',
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
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('<question>ok')
|
||||
expect(malformedHtml).not.toContain('&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
Reference in New Issue
Block a user