feat: markdown 增量解析
This commit is contained in:
12
packages/client/ui-primitives/tests/fixtures/markdown-dom/blockquote-nested.settled.txt
vendored
Normal file
12
packages/client/ui-primitives/tests/fixtures/markdown-dom/blockquote-nested.settled.txt
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
<div class="_markdown_404681">
|
||||
<blockquote>
|
||||
<p>
|
||||
#text "level one\nstill one"
|
||||
<blockquote>
|
||||
<p>
|
||||
#text "nested"
|
||||
<ul>
|
||||
<li>
|
||||
#text "quoted list"
|
||||
<p>
|
||||
#text "after"
|
||||
12
packages/client/ui-primitives/tests/fixtures/markdown-dom/blockquote-nested.streaming.txt
vendored
Normal file
12
packages/client/ui-primitives/tests/fixtures/markdown-dom/blockquote-nested.streaming.txt
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
<div class="_markdown_404681">
|
||||
<blockquote>
|
||||
<p>
|
||||
#text "level one\nstill one"
|
||||
<blockquote>
|
||||
<p>
|
||||
#text "nested"
|
||||
<ul>
|
||||
<li>
|
||||
#text "quoted list"
|
||||
<p>
|
||||
#text "after"
|
||||
78
packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.settled.txt
vendored
Normal file
78
packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.settled.txt
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
<div class="_markdown_404681">
|
||||
<div class="_block_9aea57 md-code-block">
|
||||
<div class="_bannerWrap_9aea57">
|
||||
<div class="_banner_9aea57">
|
||||
<div class="_infostring_9aea57">
|
||||
#text "ts"
|
||||
<div class="_action_9aea57">
|
||||
<button class="_copyButton_9aea57" type="button">
|
||||
#text "复制"
|
||||
<div>
|
||||
<pre class="shiki css-variables" style="background-color:var(--shiki-background);color:var(--shiki-foreground)" tabindex="0">
|
||||
<code>
|
||||
<span class="line">
|
||||
<span style="color:var(--shiki-token-keyword)">
|
||||
#text "const"
|
||||
<span style="color:var(--shiki-token-constant)">
|
||||
#text " answer"
|
||||
<span style="color:var(--shiki-token-keyword)">
|
||||
#text ":"
|
||||
<span style="color:var(--shiki-token-constant)">
|
||||
#text " number"
|
||||
<span style="color:var(--shiki-token-keyword)">
|
||||
#text " ="
|
||||
<span style="color:var(--shiki-token-constant)">
|
||||
#text " 42"
|
||||
<div class="_block_9aea57 md-code-block">
|
||||
<div class="_bannerWrap_9aea57">
|
||||
<div class="_banner_9aea57">
|
||||
<div class="_infostring_9aea57">
|
||||
<div class="_action_9aea57">
|
||||
<button class="_copyButton_9aea57" type="button">
|
||||
#text "复制"
|
||||
<pre class="_plain_9aea57">
|
||||
<code>
|
||||
#text "no language"
|
||||
<div class="_block_9aea57 md-code-block">
|
||||
<div class="_bannerWrap_9aea57">
|
||||
<div class="_banner_9aea57">
|
||||
<div class="_infostring_9aea57">
|
||||
#text "unknown-lang"
|
||||
<div class="_action_9aea57">
|
||||
<button class="_copyButton_9aea57" type="button">
|
||||
#text "复制"
|
||||
<pre class="_plain_9aea57">
|
||||
<code>
|
||||
#text "plain fallback"
|
||||
<div class="_block_9aea57 md-code-block">
|
||||
<div class="_bannerWrap_9aea57">
|
||||
<div class="_banner_9aea57">
|
||||
<div class="_infostring_9aea57">
|
||||
#text "ts"
|
||||
<div class="_action_9aea57">
|
||||
<button class="_copyButton_9aea57" type="button">
|
||||
#text "复制"
|
||||
<div>
|
||||
<pre class="shiki css-variables" style="background-color:var(--shiki-background);color:var(--shiki-foreground)" tabindex="0">
|
||||
<code>
|
||||
<span class="line">
|
||||
<span style="color:var(--shiki-token-keyword)">
|
||||
#text "const"
|
||||
<span style="color:var(--shiki-token-constant)">
|
||||
#text " withMeta"
|
||||
<span style="color:var(--shiki-token-keyword)">
|
||||
#text " ="
|
||||
<span style="color:var(--shiki-token-constant)">
|
||||
#text " true"
|
||||
<pre>
|
||||
<code>
|
||||
<div class="_block_9aea57 md-code-block">
|
||||
<div class="_bannerWrap_9aea57">
|
||||
<div class="_banner_9aea57">
|
||||
<div class="_infostring_9aea57">
|
||||
<div class="_action_9aea57">
|
||||
<button class="_copyButton_9aea57" type="button">
|
||||
#text "复制"
|
||||
<pre class="_plain_9aea57">
|
||||
<code>
|
||||
#text "indented code block\nsecond line"
|
||||
53
packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.streaming.txt
vendored
Normal file
53
packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.streaming.txt
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
<div class="_markdown_404681">
|
||||
<div class="_block_9aea57 md-code-block">
|
||||
<div class="_bannerWrap_9aea57">
|
||||
<div class="_banner_9aea57">
|
||||
<div class="_infostring_9aea57">
|
||||
<div class="_action_9aea57">
|
||||
<button class="_copyButton_9aea57" type="button">
|
||||
#text "复制"
|
||||
<pre class="_plain_9aea57">
|
||||
<code>
|
||||
#text "const answer: number = 42"
|
||||
<div class="_block_9aea57 md-code-block">
|
||||
<div class="_bannerWrap_9aea57">
|
||||
<div class="_banner_9aea57">
|
||||
<div class="_infostring_9aea57">
|
||||
<div class="_action_9aea57">
|
||||
<button class="_copyButton_9aea57" type="button">
|
||||
#text "复制"
|
||||
<pre class="_plain_9aea57">
|
||||
<code>
|
||||
#text "no language"
|
||||
<div class="_block_9aea57 md-code-block">
|
||||
<div class="_bannerWrap_9aea57">
|
||||
<div class="_banner_9aea57">
|
||||
<div class="_infostring_9aea57">
|
||||
<div class="_action_9aea57">
|
||||
<button class="_copyButton_9aea57" type="button">
|
||||
#text "复制"
|
||||
<pre class="_plain_9aea57">
|
||||
<code>
|
||||
#text "plain fallback"
|
||||
<div class="_block_9aea57 md-code-block">
|
||||
<div class="_bannerWrap_9aea57">
|
||||
<div class="_banner_9aea57">
|
||||
<div class="_infostring_9aea57">
|
||||
<div class="_action_9aea57">
|
||||
<button class="_copyButton_9aea57" type="button">
|
||||
#text "复制"
|
||||
<pre class="_plain_9aea57">
|
||||
<code>
|
||||
#text "const withMeta = true"
|
||||
<pre>
|
||||
<code>
|
||||
<div class="_block_9aea57 md-code-block">
|
||||
<div class="_bannerWrap_9aea57">
|
||||
<div class="_banner_9aea57">
|
||||
<div class="_infostring_9aea57">
|
||||
<div class="_action_9aea57">
|
||||
<button class="_copyButton_9aea57" type="button">
|
||||
#text "复制"
|
||||
<pre class="_plain_9aea57">
|
||||
<code>
|
||||
#text "indented code block\nsecond line"
|
||||
1
packages/client/ui-primitives/tests/fixtures/markdown-dom/definition-only.settled.txt
vendored
Normal file
1
packages/client/ui-primitives/tests/fixtures/markdown-dom/definition-only.settled.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<div class="_markdown_404681">
|
||||
1
packages/client/ui-primitives/tests/fixtures/markdown-dom/definition-only.streaming.txt
vendored
Normal file
1
packages/client/ui-primitives/tests/fixtures/markdown-dom/definition-only.streaming.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<div class="_markdown_404681">
|
||||
3
packages/client/ui-primitives/tests/fixtures/markdown-dom/entities-and-escapes.settled.txt
vendored
Normal file
3
packages/client/ui-primitives/tests/fixtures/markdown-dom/entities-and-escapes.settled.txt
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
<div class="_markdown_404681">
|
||||
<p>
|
||||
#text "AT&T, 3 < 4, *not em*, backslash \\ literal, © entity."
|
||||
3
packages/client/ui-primitives/tests/fixtures/markdown-dom/entities-and-escapes.streaming.txt
vendored
Normal file
3
packages/client/ui-primitives/tests/fixtures/markdown-dom/entities-and-escapes.streaming.txt
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
<div class="_markdown_404681">
|
||||
<p>
|
||||
#text "AT&T, 3 < 4, *not em*, backslash \\ literal, © entity."
|
||||
29
packages/client/ui-primitives/tests/fixtures/markdown-dom/footnotes.settled.txt
vendored
Normal file
29
packages/client/ui-primitives/tests/fixtures/markdown-dom/footnotes.settled.txt
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
<div class="_markdown_404681">
|
||||
<p>
|
||||
#text "First use"
|
||||
<sup>
|
||||
#text "1"
|
||||
#text " and reuse"
|
||||
<sup>
|
||||
#text "1"
|
||||
#text " and another"
|
||||
<sup>
|
||||
#text "2"
|
||||
#text "."
|
||||
<section class="footnotes" data-footnotes="true">
|
||||
<h2 class="sr-only" id="footnote-label">
|
||||
#text "Footnotes"
|
||||
<ol>
|
||||
<li id="user-content-fn-a">
|
||||
<p>
|
||||
#text "Footnote a body with "
|
||||
<a href="https://example.com" rel="noopener noreferrer" target="_blank">
|
||||
#text "link"
|
||||
#text ". ↩ ↩"
|
||||
<sup>
|
||||
#text "2"
|
||||
<li id="user-content-fn-b">
|
||||
<p>
|
||||
#text "Footnote b first paragraph."
|
||||
<p>
|
||||
#text "Second paragraph of b. ↩"
|
||||
29
packages/client/ui-primitives/tests/fixtures/markdown-dom/footnotes.streaming.txt
vendored
Normal file
29
packages/client/ui-primitives/tests/fixtures/markdown-dom/footnotes.streaming.txt
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
<div class="_markdown_404681">
|
||||
<p>
|
||||
#text "First use"
|
||||
<sup>
|
||||
#text "1"
|
||||
#text " and reuse"
|
||||
<sup>
|
||||
#text "1"
|
||||
#text " and another"
|
||||
<sup>
|
||||
#text "2"
|
||||
#text "."
|
||||
<section class="footnotes" data-footnotes="true">
|
||||
<h2 class="sr-only" id="footnote-label">
|
||||
#text "Footnotes"
|
||||
<ol>
|
||||
<li id="user-content-fn-a">
|
||||
<p>
|
||||
#text "Footnote a body with "
|
||||
<a href="https://example.com" rel="noopener noreferrer" target="_blank">
|
||||
#text "link"
|
||||
#text ". ↩ ↩"
|
||||
<sup>
|
||||
#text "2"
|
||||
<li id="user-content-fn-b">
|
||||
<p>
|
||||
#text "Footnote b first paragraph."
|
||||
<p>
|
||||
#text "Second paragraph of b. ↩"
|
||||
@@ -0,0 +1,12 @@
|
||||
<div class="_markdown_404681">
|
||||
<p>
|
||||
#text "Mixed "
|
||||
<del>
|
||||
#text "gone"
|
||||
#text " text with "
|
||||
<a href="http://www.example.com" rel="noopener noreferrer" target="_blank">
|
||||
#text "www.example.com"
|
||||
#text " literal and "
|
||||
<a href="mailto:user@example.com">
|
||||
#text "user@example.com"
|
||||
#text " email."
|
||||
@@ -0,0 +1,12 @@
|
||||
<div class="_markdown_404681">
|
||||
<p>
|
||||
#text "Mixed "
|
||||
<del>
|
||||
#text "gone"
|
||||
#text " text with "
|
||||
<a href="http://www.example.com" rel="noopener noreferrer" target="_blank">
|
||||
#text "www.example.com"
|
||||
#text " literal and "
|
||||
<a href="mailto:user@example.com">
|
||||
#text "user@example.com"
|
||||
#text " email."
|
||||
12
packages/client/ui-primitives/tests/fixtures/markdown-dom/hard-breaks-and-hr.settled.txt
vendored
Normal file
12
packages/client/ui-primitives/tests/fixtures/markdown-dom/hard-breaks-and-hr.settled.txt
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
<div class="_markdown_404681">
|
||||
<p>
|
||||
#text "two-space break"
|
||||
<br>
|
||||
#text "\nafter break"
|
||||
<p>
|
||||
#text "backslash break"
|
||||
<br>
|
||||
#text "\nafter backslash"
|
||||
<hr>
|
||||
<p>
|
||||
#text "tail"
|
||||
12
packages/client/ui-primitives/tests/fixtures/markdown-dom/hard-breaks-and-hr.streaming.txt
vendored
Normal file
12
packages/client/ui-primitives/tests/fixtures/markdown-dom/hard-breaks-and-hr.streaming.txt
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
<div class="_markdown_404681">
|
||||
<p>
|
||||
#text "two-space break"
|
||||
<br>
|
||||
#text "\nafter break"
|
||||
<p>
|
||||
#text "backslash break"
|
||||
<br>
|
||||
#text "\nafter backslash"
|
||||
<hr>
|
||||
<p>
|
||||
#text "tail"
|
||||
15
packages/client/ui-primitives/tests/fixtures/markdown-dom/heading-tight-against-list.settled.txt
vendored
Normal file
15
packages/client/ui-primitives/tests/fixtures/markdown-dom/heading-tight-against-list.settled.txt
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
<div class="_markdown_404681">
|
||||
<h4>
|
||||
#text "Small heading"
|
||||
<ul>
|
||||
<li>
|
||||
#text "one"
|
||||
<li>
|
||||
#text "two"
|
||||
<h5>
|
||||
#text "Next"
|
||||
<ol>
|
||||
<li>
|
||||
#text "a"
|
||||
<li>
|
||||
#text "b"
|
||||
@@ -0,0 +1,15 @@
|
||||
<div class="_markdown_404681">
|
||||
<h4>
|
||||
#text "Small heading"
|
||||
<ul>
|
||||
<li>
|
||||
#text "one"
|
||||
<li>
|
||||
#text "two"
|
||||
<h5>
|
||||
#text "Next"
|
||||
<ol>
|
||||
<li>
|
||||
#text "a"
|
||||
<li>
|
||||
#text "b"
|
||||
33
packages/client/ui-primitives/tests/fixtures/markdown-dom/headings-and-paragraphs.settled.txt
vendored
Normal file
33
packages/client/ui-primitives/tests/fixtures/markdown-dom/headings-and-paragraphs.settled.txt
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
<div class="_markdown_404681">
|
||||
<h1>
|
||||
#text "H1 with "
|
||||
<code>
|
||||
#text "code"
|
||||
<h2>
|
||||
#text "H2"
|
||||
<h3>
|
||||
#text "H3"
|
||||
<h4>
|
||||
#text "H4"
|
||||
<h5>
|
||||
#text "H5"
|
||||
<h6>
|
||||
#text "H6"
|
||||
<p>
|
||||
#text "Paragraph one with "
|
||||
<strong>
|
||||
#text "strong"
|
||||
#text ", "
|
||||
<em>
|
||||
#text "emphasis"
|
||||
#text ", "
|
||||
<del>
|
||||
#text "strike"
|
||||
#text ", and "
|
||||
<code>
|
||||
#text "inline"
|
||||
#text "."
|
||||
<h1>
|
||||
#text "Setext title"
|
||||
<h2>
|
||||
#text "Second setext"
|
||||
33
packages/client/ui-primitives/tests/fixtures/markdown-dom/headings-and-paragraphs.streaming.txt
vendored
Normal file
33
packages/client/ui-primitives/tests/fixtures/markdown-dom/headings-and-paragraphs.streaming.txt
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
<div class="_markdown_404681">
|
||||
<h1>
|
||||
#text "H1 with "
|
||||
<code>
|
||||
#text "code"
|
||||
<h2>
|
||||
#text "H2"
|
||||
<h3>
|
||||
#text "H3"
|
||||
<h4>
|
||||
#text "H4"
|
||||
<h5>
|
||||
#text "H5"
|
||||
<h6>
|
||||
#text "H6"
|
||||
<p>
|
||||
#text "Paragraph one with "
|
||||
<strong>
|
||||
#text "strong"
|
||||
#text ", "
|
||||
<em>
|
||||
#text "emphasis"
|
||||
#text ", "
|
||||
<del>
|
||||
#text "strike"
|
||||
#text ", and "
|
||||
<code>
|
||||
#text "inline"
|
||||
#text "."
|
||||
<h1>
|
||||
#text "Setext title"
|
||||
<h2>
|
||||
#text "Second setext"
|
||||
14
packages/client/ui-primitives/tests/fixtures/markdown-dom/images.settled.txt
vendored
Normal file
14
packages/client/ui-primitives/tests/fixtures/markdown-dom/images.settled.txt
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
<div class="_markdown_404681">
|
||||
<p>
|
||||
<img alt="https image" class="_image_404681" decoding="async" loading="lazy" referrerpolicy="no-referrer" src="https://example.com/secure.png">
|
||||
<p>
|
||||
<img alt="http image" class="_image_404681" decoding="async" loading="lazy" referrerpolicy="no-referrer" src="http://example.com/plain.png">
|
||||
<p>
|
||||
<span class="_imageAlt_404681">
|
||||
#text "relative dropped"
|
||||
#text " and inline "
|
||||
<span class="_imageAlt_404681">
|
||||
#text "bad scheme"
|
||||
#text " end."
|
||||
<p>
|
||||
<img alt="" class="_image_404681" decoding="async" loading="lazy" referrerpolicy="no-referrer" src="https://example.com/empty-alt.png">
|
||||
14
packages/client/ui-primitives/tests/fixtures/markdown-dom/images.streaming.txt
vendored
Normal file
14
packages/client/ui-primitives/tests/fixtures/markdown-dom/images.streaming.txt
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
<div class="_markdown_404681">
|
||||
<p>
|
||||
<img alt="https image" class="_image_404681" decoding="async" loading="lazy" referrerpolicy="no-referrer" src="https://example.com/secure.png">
|
||||
<p>
|
||||
<img alt="http image" class="_image_404681" decoding="async" loading="lazy" referrerpolicy="no-referrer" src="http://example.com/plain.png">
|
||||
<p>
|
||||
<span class="_imageAlt_404681">
|
||||
#text "relative dropped"
|
||||
#text " and inline "
|
||||
<span class="_imageAlt_404681">
|
||||
#text "bad scheme"
|
||||
#text " end."
|
||||
<p>
|
||||
<img alt="" class="_image_404681" decoding="async" loading="lazy" referrerpolicy="no-referrer" src="https://example.com/empty-alt.png">
|
||||
@@ -0,0 +1,6 @@
|
||||
<div class="_markdown_404681">
|
||||
<p>
|
||||
#text "Spans "
|
||||
<code>
|
||||
#text "a b"
|
||||
#text " across a line."
|
||||
@@ -0,0 +1,6 @@
|
||||
<div class="_markdown_404681">
|
||||
<p>
|
||||
#text "Spans "
|
||||
<code>
|
||||
#text "a b"
|
||||
#text " across a line."
|
||||
25
packages/client/ui-primitives/tests/fixtures/markdown-dom/links-and-autolinks.settled.txt
vendored
Normal file
25
packages/client/ui-primitives/tests/fixtures/markdown-dom/links-and-autolinks.settled.txt
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
<div class="_markdown_404681">
|
||||
<p>
|
||||
<a href="https://example.com" rel="noopener noreferrer" target="_blank">
|
||||
#text "https ok"
|
||||
#text " and "
|
||||
<a href="mailto:dev@example.com">
|
||||
#text "mailto ok"
|
||||
#text "."
|
||||
<p>
|
||||
#text "relative dropped and js dropped and "
|
||||
<a href="HTTPS://example.com" rel="noopener noreferrer" target="_blank">
|
||||
#text "upper kept"
|
||||
#text "."
|
||||
<p>
|
||||
<a href="https://deepseek.com" rel="noopener noreferrer" target="_blank">
|
||||
#text "https://deepseek.com"
|
||||
#text " and bare autolink "
|
||||
<a href="https://autolink.example.com" rel="noopener noreferrer" target="_blank">
|
||||
#text "https://autolink.example.com"
|
||||
#text " literal."
|
||||
<p>
|
||||
#text "[spaces encoded]("
|
||||
<a href="https://example.com/a" rel="noopener noreferrer" target="_blank">
|
||||
#text "https://example.com/a"
|
||||
#text " b)"
|
||||
25
packages/client/ui-primitives/tests/fixtures/markdown-dom/links-and-autolinks.streaming.txt
vendored
Normal file
25
packages/client/ui-primitives/tests/fixtures/markdown-dom/links-and-autolinks.streaming.txt
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
<div class="_markdown_404681">
|
||||
<p>
|
||||
<a href="https://example.com" rel="noopener noreferrer" target="_blank">
|
||||
#text "https ok"
|
||||
#text " and "
|
||||
<a href="mailto:dev@example.com">
|
||||
#text "mailto ok"
|
||||
#text "."
|
||||
<p>
|
||||
#text "relative dropped and js dropped and "
|
||||
<a href="HTTPS://example.com" rel="noopener noreferrer" target="_blank">
|
||||
#text "upper kept"
|
||||
#text "."
|
||||
<p>
|
||||
<a href="https://deepseek.com" rel="noopener noreferrer" target="_blank">
|
||||
#text "https://deepseek.com"
|
||||
#text " and bare autolink "
|
||||
<a href="https://autolink.example.com" rel="noopener noreferrer" target="_blank">
|
||||
#text "https://autolink.example.com"
|
||||
#text " literal."
|
||||
<p>
|
||||
#text "[spaces encoded]("
|
||||
<a href="https://example.com/a" rel="noopener noreferrer" target="_blank">
|
||||
#text "https://example.com/a"
|
||||
#text " b)"
|
||||
44
packages/client/ui-primitives/tests/fixtures/markdown-dom/lists-tight-loose-nested.settled.txt
vendored
Normal file
44
packages/client/ui-primitives/tests/fixtures/markdown-dom/lists-tight-loose-nested.settled.txt
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
<div class="_markdown_404681">
|
||||
<ul>
|
||||
<li>
|
||||
#text "tight one"
|
||||
<li>
|
||||
#text "tight two\n"
|
||||
<ul>
|
||||
<li>
|
||||
#text "child"
|
||||
<ol>
|
||||
<li>
|
||||
<p>
|
||||
#text "first"
|
||||
<li>
|
||||
<p>
|
||||
#text "second"
|
||||
<li>
|
||||
<p>
|
||||
#text "ordered with start"
|
||||
<li>
|
||||
<p>
|
||||
#text "next"
|
||||
<ul>
|
||||
<li>
|
||||
<p>
|
||||
#text "loose item one"
|
||||
<li>
|
||||
<p>
|
||||
#text "loose item two"
|
||||
<p>
|
||||
#text "second paragraph of loose item"
|
||||
<li>
|
||||
<p>
|
||||
#text "item with nested blocks"
|
||||
<div class="_block_9aea57 md-code-block">
|
||||
<div class="_bannerWrap_9aea57">
|
||||
<div class="_banner_9aea57">
|
||||
<div class="_infostring_9aea57">
|
||||
<div class="_action_9aea57">
|
||||
<button class="_copyButton_9aea57" type="button">
|
||||
#text "复制"
|
||||
<pre class="_plain_9aea57">
|
||||
<code>
|
||||
#text "fenced inside list"
|
||||
44
packages/client/ui-primitives/tests/fixtures/markdown-dom/lists-tight-loose-nested.streaming.txt
vendored
Normal file
44
packages/client/ui-primitives/tests/fixtures/markdown-dom/lists-tight-loose-nested.streaming.txt
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
<div class="_markdown_404681">
|
||||
<ul>
|
||||
<li>
|
||||
#text "tight one"
|
||||
<li>
|
||||
#text "tight two\n"
|
||||
<ul>
|
||||
<li>
|
||||
#text "child"
|
||||
<ol>
|
||||
<li>
|
||||
<p>
|
||||
#text "first"
|
||||
<li>
|
||||
<p>
|
||||
#text "second"
|
||||
<li>
|
||||
<p>
|
||||
#text "ordered with start"
|
||||
<li>
|
||||
<p>
|
||||
#text "next"
|
||||
<ul>
|
||||
<li>
|
||||
<p>
|
||||
#text "loose item one"
|
||||
<li>
|
||||
<p>
|
||||
#text "loose item two"
|
||||
<p>
|
||||
#text "second paragraph of loose item"
|
||||
<li>
|
||||
<p>
|
||||
#text "item with nested blocks"
|
||||
<div class="_block_9aea57 md-code-block">
|
||||
<div class="_bannerWrap_9aea57">
|
||||
<div class="_banner_9aea57">
|
||||
<div class="_infostring_9aea57">
|
||||
<div class="_action_9aea57">
|
||||
<button class="_copyButton_9aea57" type="button">
|
||||
#text "复制"
|
||||
<pre class="_plain_9aea57">
|
||||
<code>
|
||||
#text "fenced inside list"
|
||||
125
packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.settled.txt
vendored
Normal file
125
packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.settled.txt
vendored
Normal file
@@ -0,0 +1,125 @@
|
||||
<div class="_markdown_404681">
|
||||
<p>
|
||||
#text "Trusted commands stay off: "
|
||||
<span class="katex">
|
||||
<span class="katex-mathml">
|
||||
<math xmlns="http://www.w3.org/1998/Math/MathML">
|
||||
<semantics>
|
||||
<mrow>
|
||||
<mstyle mathcolor="#cc0000">
|
||||
<mtext>
|
||||
#text "\\href"
|
||||
<annotation encoding="application/x-tex">
|
||||
#text "\\href{javascript:alert(1)}{unsafe}"
|
||||
<span aria-hidden="true" class="katex-html">
|
||||
<span class="base">
|
||||
<span class="strut" style="height: 1em; vertical-align: -0.25em;">
|
||||
<span class="mord text" style="color: rgb(204, 0, 0);">
|
||||
<span class="mord" style="color: rgb(204, 0, 0);">
|
||||
#text "\\href"
|
||||
#text "."
|
||||
<p>
|
||||
#text "Unbalanced errors render the error arm: "
|
||||
<span class="katex-error" style="color: rgb(204, 0, 0);" title="ParseError: KaTeX parse error: Unexpected end of input in a macro argument, expected '}' at end of input: \\frac{">
|
||||
#text "\\frac{"
|
||||
<div class="_tableScroll_404681">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
#text "Symbol"
|
||||
<th>
|
||||
#text "Value"
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<span class="katex">
|
||||
<span class="katex-mathml">
|
||||
<math xmlns="http://www.w3.org/1998/Math/MathML">
|
||||
<semantics>
|
||||
<mrow>
|
||||
<mi>
|
||||
#text "θ"
|
||||
<annotation encoding="application/x-tex">
|
||||
#text "\\theta"
|
||||
<span aria-hidden="true" class="katex-html">
|
||||
<span class="base">
|
||||
<span class="strut" style="height: 0.6944em;">
|
||||
<span class="mord mathnormal" style="margin-right: 0.0278em;">
|
||||
#text "θ"
|
||||
<td>
|
||||
<span class="katex">
|
||||
<span class="katex-mathml">
|
||||
<math xmlns="http://www.w3.org/1998/Math/MathML">
|
||||
<semantics>
|
||||
<mrow>
|
||||
<mfrac>
|
||||
<mn>
|
||||
#text "1"
|
||||
<mn>
|
||||
#text "5"
|
||||
<annotation encoding="application/x-tex">
|
||||
#text "\\frac{1}{5}"
|
||||
<span aria-hidden="true" class="katex-html">
|
||||
<span class="base">
|
||||
<span class="strut" style="height: 1.1901em; vertical-align: -0.345em;">
|
||||
<span class="mord">
|
||||
<span class="mopen nulldelimiter">
|
||||
<span class="mfrac">
|
||||
<span class="vlist-t vlist-t2">
|
||||
<span class="vlist-r">
|
||||
<span class="vlist" style="height: 0.8451em;">
|
||||
<span style="top: -2.655em;">
|
||||
<span class="pstrut" style="height: 3em;">
|
||||
<span class="sizing reset-size6 size3 mtight">
|
||||
<span class="mord mtight">
|
||||
<span class="mord mtight">
|
||||
#text "5"
|
||||
<span style="top: -3.23em;">
|
||||
<span class="pstrut" style="height: 3em;">
|
||||
<span class="frac-line" style="border-bottom-width: 0.04em;">
|
||||
<span style="top: -3.394em;">
|
||||
<span class="pstrut" style="height: 3em;">
|
||||
<span class="sizing reset-size6 size3 mtight">
|
||||
<span class="mord mtight">
|
||||
<span class="mord mtight">
|
||||
#text "1"
|
||||
<span class="vlist-s">
|
||||
#text ""
|
||||
<span class="vlist-r">
|
||||
<span class="vlist" style="height: 0.345em;">
|
||||
<span>
|
||||
<span class="mclose nulldelimiter">
|
||||
<span class="katex-display">
|
||||
<span class="katex">
|
||||
<span class="katex-mathml">
|
||||
<math display="block" xmlns="http://www.w3.org/1998/Math/MathML">
|
||||
<semantics>
|
||||
<mrow>
|
||||
<msqrt>
|
||||
<mn>
|
||||
#text "2"
|
||||
<annotation encoding="application/x-tex">
|
||||
#text "\\sqrt{2}\n"
|
||||
<span aria-hidden="true" class="katex-html">
|
||||
<span class="base">
|
||||
<span class="strut" style="height: 1.04em; vertical-align: -0.0839em;">
|
||||
<span class="mord sqrt">
|
||||
<span class="vlist-t vlist-t2">
|
||||
<span class="vlist-r">
|
||||
<span class="vlist" style="height: 0.9561em;">
|
||||
<span class="svg-align" style="top: -3em;">
|
||||
<span class="pstrut" style="height: 3em;">
|
||||
<span class="mord" style="padding-left: 0.833em;">
|
||||
<span class="mord">
|
||||
#text "2"
|
||||
<span style="top: -2.9161em;">
|
||||
<span class="pstrut" style="height: 3em;">
|
||||
<span class="hide-tail" style="min-width: 0.853em; height: 1.08em;">
|
||||
<svg height="1.08em" preserveAspectRatio="xMinYMin slice" viewBox="0 0 400000 1080" width="400em" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M95,702\nc-2.7,0,-7.17,-2.7,-13.5,-8c-5.8,-5.3,-9.5,-10,-9.5,-14\nc0,-2,0.3,-3.3,1,-4c1.3,-2.7,23.83,-20.7,67.5,-54\nc44.2,-33.3,65.8,-50.3,66.5,-51c1.3,-1.3,3,-2,5,-2c4.7,0,8.7,3.3,12,10\ns173,378,173,378c0.7,0,35.3,-71,104,-213c68.7,-142,137.5,-285,206.5,-429\nc69,-144,104.5,-217.7,106.5,-221\nl0 -0\nc5.3,-9.3,12,-14,20,-14\nH400000v40H845.2724\ns-225.272,467,-225.272,467s-235,486,-235,486c-2.7,4.7,-9,7,-19,7\nc-6,0,-10,-1,-12,-3s-194,-422,-194,-422s-65,47,-65,47z\nM834 80h400000v40h-400000z">
|
||||
<span class="vlist-s">
|
||||
#text ""
|
||||
<span class="vlist-r">
|
||||
<span class="vlist" style="height: 0.0839em;">
|
||||
<span>
|
||||
29
packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.streaming.txt
vendored
Normal file
29
packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.streaming.txt
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
<div class="_markdown_404681">
|
||||
<p>
|
||||
#text "Trusted commands stay off: $\\href{javascript:alert(1)}{unsafe}$."
|
||||
<p>
|
||||
#text "Unbalanced errors render the error arm: $\\frac{$"
|
||||
<div class="_tableScroll_404681">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
#text "Symbol"
|
||||
<th>
|
||||
#text "Value"
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
#text "$\\theta$"
|
||||
<td>
|
||||
#text "(\\frac{1}{5})"
|
||||
<div class="_block_9aea57 md-code-block">
|
||||
<div class="_bannerWrap_9aea57">
|
||||
<div class="_banner_9aea57">
|
||||
<div class="_infostring_9aea57">
|
||||
<div class="_action_9aea57">
|
||||
<button class="_copyButton_9aea57" type="button">
|
||||
#text "复制"
|
||||
<pre class="_plain_9aea57">
|
||||
<code>
|
||||
#text "\\sqrt{2}"
|
||||
320
packages/client/ui-primitives/tests/fixtures/markdown-dom/math-inline-and-display.settled.txt
vendored
Normal file
320
packages/client/ui-primitives/tests/fixtures/markdown-dom/math-inline-and-display.settled.txt
vendored
Normal file
@@ -0,0 +1,320 @@
|
||||
<div class="_markdown_404681">
|
||||
<p>
|
||||
#text "Einstein wrote "
|
||||
<span class="katex">
|
||||
<span class="katex-mathml">
|
||||
<math xmlns="http://www.w3.org/1998/Math/MathML">
|
||||
<semantics>
|
||||
<mrow>
|
||||
<mi>
|
||||
#text "E"
|
||||
<mo>
|
||||
#text "="
|
||||
<mi>
|
||||
#text "m"
|
||||
<msup>
|
||||
<mi>
|
||||
#text "c"
|
||||
<mn>
|
||||
#text "2"
|
||||
<annotation encoding="application/x-tex">
|
||||
#text "E = mc^2"
|
||||
<span aria-hidden="true" class="katex-html">
|
||||
<span class="base">
|
||||
<span class="strut" style="height: 0.6833em;">
|
||||
<span class="mord mathnormal" style="margin-right: 0.0576em;">
|
||||
#text "E"
|
||||
<span class="mspace" style="margin-right: 0.2778em;">
|
||||
<span class="mrel">
|
||||
#text "="
|
||||
<span class="mspace" style="margin-right: 0.2778em;">
|
||||
<span class="base">
|
||||
<span class="strut" style="height: 0.8141em;">
|
||||
<span class="mord mathnormal">
|
||||
#text "m"
|
||||
<span class="mord">
|
||||
<span class="mord mathnormal">
|
||||
#text "c"
|
||||
<span class="msupsub">
|
||||
<span class="vlist-t">
|
||||
<span class="vlist-r">
|
||||
<span class="vlist" style="height: 0.8141em;">
|
||||
<span style="top: -3.063em; margin-right: 0.05em;">
|
||||
<span class="pstrut" style="height: 2.7em;">
|
||||
<span class="sizing reset-size6 size3 mtight">
|
||||
<span class="mord mtight">
|
||||
#text "2"
|
||||
#text " inline."
|
||||
<span class="katex-display">
|
||||
<span class="katex">
|
||||
<span class="katex-mathml">
|
||||
<math display="block" xmlns="http://www.w3.org/1998/Math/MathML">
|
||||
<semantics>
|
||||
<mrow>
|
||||
<mfrac>
|
||||
<mrow>
|
||||
<mi mathvariant="normal">
|
||||
#text "∂"
|
||||
<mi mathvariant="bold">
|
||||
#text "u"
|
||||
<mrow>
|
||||
<mi mathvariant="normal">
|
||||
#text "∂"
|
||||
<mi>
|
||||
#text "t"
|
||||
<mo>
|
||||
#text "+"
|
||||
<mo stretchy="false">
|
||||
#text "("
|
||||
<mi mathvariant="bold">
|
||||
#text "u"
|
||||
<mo>
|
||||
#text "⋅"
|
||||
<mi mathvariant="normal">
|
||||
#text "∇"
|
||||
<mo stretchy="false">
|
||||
#text ")"
|
||||
<mi mathvariant="bold">
|
||||
#text "u"
|
||||
<mo>
|
||||
#text "="
|
||||
<mo>
|
||||
#text "−"
|
||||
<mfrac>
|
||||
<mn>
|
||||
#text "1"
|
||||
<mi>
|
||||
#text "ρ"
|
||||
<mi mathvariant="normal">
|
||||
#text "∇"
|
||||
<mi>
|
||||
#text "p"
|
||||
<annotation encoding="application/x-tex">
|
||||
#text "\\frac{\\partial \\mathbf{u}}{\\partial t} + (\\mathbf{u} \\cdot \\nabla)\\mathbf{u} = -\\frac{1}{\\rho}\\nabla p"
|
||||
<span aria-hidden="true" class="katex-html">
|
||||
<span class="base">
|
||||
<span class="strut" style="height: 2.0574em; vertical-align: -0.686em;">
|
||||
<span class="mord">
|
||||
<span class="mopen nulldelimiter">
|
||||
<span class="mfrac">
|
||||
<span class="vlist-t vlist-t2">
|
||||
<span class="vlist-r">
|
||||
<span class="vlist" style="height: 1.3714em;">
|
||||
<span style="top: -2.314em;">
|
||||
<span class="pstrut" style="height: 3em;">
|
||||
<span class="mord">
|
||||
<span class="mord" style="margin-right: 0.0556em;">
|
||||
#text "∂"
|
||||
<span class="mord mathnormal">
|
||||
#text "t"
|
||||
<span style="top: -3.23em;">
|
||||
<span class="pstrut" style="height: 3em;">
|
||||
<span class="frac-line" style="border-bottom-width: 0.04em;">
|
||||
<span style="top: -3.677em;">
|
||||
<span class="pstrut" style="height: 3em;">
|
||||
<span class="mord">
|
||||
<span class="mord" style="margin-right: 0.0556em;">
|
||||
#text "∂"
|
||||
<span class="mord mathbf">
|
||||
#text "u"
|
||||
<span class="vlist-s">
|
||||
#text ""
|
||||
<span class="vlist-r">
|
||||
<span class="vlist" style="height: 0.686em;">
|
||||
<span>
|
||||
<span class="mclose nulldelimiter">
|
||||
<span class="mspace" style="margin-right: 0.2222em;">
|
||||
<span class="mbin">
|
||||
#text "+"
|
||||
<span class="mspace" style="margin-right: 0.2222em;">
|
||||
<span class="base">
|
||||
<span class="strut" style="height: 1em; vertical-align: -0.25em;">
|
||||
<span class="mopen">
|
||||
#text "("
|
||||
<span class="mord mathbf">
|
||||
#text "u"
|
||||
<span class="mspace" style="margin-right: 0.2222em;">
|
||||
<span class="mbin">
|
||||
#text "⋅"
|
||||
<span class="mspace" style="margin-right: 0.2222em;">
|
||||
<span class="base">
|
||||
<span class="strut" style="height: 1em; vertical-align: -0.25em;">
|
||||
<span class="mord">
|
||||
#text "∇"
|
||||
<span class="mclose">
|
||||
#text ")"
|
||||
<span class="mord mathbf">
|
||||
#text "u"
|
||||
<span class="mspace" style="margin-right: 0.2778em;">
|
||||
<span class="mrel">
|
||||
#text "="
|
||||
<span class="mspace" style="margin-right: 0.2778em;">
|
||||
<span class="base">
|
||||
<span class="strut" style="height: 2.2019em; vertical-align: -0.8804em;">
|
||||
<span class="mord">
|
||||
#text "−"
|
||||
<span class="mord">
|
||||
<span class="mopen nulldelimiter">
|
||||
<span class="mfrac">
|
||||
<span class="vlist-t vlist-t2">
|
||||
<span class="vlist-r">
|
||||
<span class="vlist" style="height: 1.3214em;">
|
||||
<span style="top: -2.314em;">
|
||||
<span class="pstrut" style="height: 3em;">
|
||||
<span class="mord">
|
||||
<span class="mord mathnormal">
|
||||
#text "ρ"
|
||||
<span style="top: -3.23em;">
|
||||
<span class="pstrut" style="height: 3em;">
|
||||
<span class="frac-line" style="border-bottom-width: 0.04em;">
|
||||
<span style="top: -3.677em;">
|
||||
<span class="pstrut" style="height: 3em;">
|
||||
<span class="mord">
|
||||
<span class="mord">
|
||||
#text "1"
|
||||
<span class="vlist-s">
|
||||
#text ""
|
||||
<span class="vlist-r">
|
||||
<span class="vlist" style="height: 0.8804em;">
|
||||
<span>
|
||||
<span class="mclose nulldelimiter">
|
||||
<span class="mord">
|
||||
#text "∇"
|
||||
<span class="mord mathnormal">
|
||||
#text "p"
|
||||
<p>
|
||||
#text "Backslash inline "
|
||||
<span class="katex">
|
||||
<span class="katex-mathml">
|
||||
<math xmlns="http://www.w3.org/1998/Math/MathML">
|
||||
<semantics>
|
||||
<mrow>
|
||||
<mfrac>
|
||||
<mn>
|
||||
#text "1"
|
||||
<mn>
|
||||
#text "5"
|
||||
<annotation encoding="application/x-tex">
|
||||
#text "\\frac{1}{5}"
|
||||
<span aria-hidden="true" class="katex-html">
|
||||
<span class="base">
|
||||
<span class="strut" style="height: 1.1901em; vertical-align: -0.345em;">
|
||||
<span class="mord">
|
||||
<span class="mopen nulldelimiter">
|
||||
<span class="mfrac">
|
||||
<span class="vlist-t vlist-t2">
|
||||
<span class="vlist-r">
|
||||
<span class="vlist" style="height: 0.8451em;">
|
||||
<span style="top: -2.655em;">
|
||||
<span class="pstrut" style="height: 3em;">
|
||||
<span class="sizing reset-size6 size3 mtight">
|
||||
<span class="mord mtight">
|
||||
<span class="mord mtight">
|
||||
#text "5"
|
||||
<span style="top: -3.23em;">
|
||||
<span class="pstrut" style="height: 3em;">
|
||||
<span class="frac-line" style="border-bottom-width: 0.04em;">
|
||||
<span style="top: -3.394em;">
|
||||
<span class="pstrut" style="height: 3em;">
|
||||
<span class="sizing reset-size6 size3 mtight">
|
||||
<span class="mord mtight">
|
||||
<span class="mord mtight">
|
||||
#text "1"
|
||||
<span class="vlist-s">
|
||||
#text ""
|
||||
<span class="vlist-r">
|
||||
<span class="vlist" style="height: 0.345em;">
|
||||
<span>
|
||||
<span class="mclose nulldelimiter">
|
||||
#text " and display:"
|
||||
<span class="katex-display">
|
||||
<span class="katex">
|
||||
<span class="katex-mathml">
|
||||
<math display="block" xmlns="http://www.w3.org/1998/Math/MathML">
|
||||
<semantics>
|
||||
<mrow>
|
||||
<mfrac>
|
||||
<mi>
|
||||
#text "π"
|
||||
<mn>
|
||||
#text "4"
|
||||
<mo>
|
||||
#text "<"
|
||||
<mi>
|
||||
#text "θ"
|
||||
<mo>
|
||||
#text "<"
|
||||
<mfrac>
|
||||
<mi>
|
||||
#text "π"
|
||||
<mn>
|
||||
#text "2"
|
||||
<annotation encoding="application/x-tex">
|
||||
#text "\\frac{\\pi}{4} < \\theta < \\frac{\\pi}{2}"
|
||||
<span aria-hidden="true" class="katex-html">
|
||||
<span class="base">
|
||||
<span class="strut" style="height: 1.7936em; vertical-align: -0.686em;">
|
||||
<span class="mord">
|
||||
<span class="mopen nulldelimiter">
|
||||
<span class="mfrac">
|
||||
<span class="vlist-t vlist-t2">
|
||||
<span class="vlist-r">
|
||||
<span class="vlist" style="height: 1.1076em;">
|
||||
<span style="top: -2.314em;">
|
||||
<span class="pstrut" style="height: 3em;">
|
||||
<span class="mord">
|
||||
<span class="mord">
|
||||
#text "4"
|
||||
<span style="top: -3.23em;">
|
||||
<span class="pstrut" style="height: 3em;">
|
||||
<span class="frac-line" style="border-bottom-width: 0.04em;">
|
||||
<span style="top: -3.677em;">
|
||||
<span class="pstrut" style="height: 3em;">
|
||||
<span class="mord">
|
||||
<span class="mord mathnormal" style="margin-right: 0.0359em;">
|
||||
#text "π"
|
||||
<span class="vlist-s">
|
||||
#text ""
|
||||
<span class="vlist-r">
|
||||
<span class="vlist" style="height: 0.686em;">
|
||||
<span>
|
||||
<span class="mclose nulldelimiter">
|
||||
<span class="mspace" style="margin-right: 0.2778em;">
|
||||
<span class="mrel">
|
||||
#text "<"
|
||||
<span class="mspace" style="margin-right: 0.2778em;">
|
||||
<span class="base">
|
||||
<span class="strut" style="height: 0.7335em; vertical-align: -0.0391em;">
|
||||
<span class="mord mathnormal" style="margin-right: 0.0278em;">
|
||||
#text "θ"
|
||||
<span class="mspace" style="margin-right: 0.2778em;">
|
||||
<span class="mrel">
|
||||
#text "<"
|
||||
<span class="mspace" style="margin-right: 0.2778em;">
|
||||
<span class="base">
|
||||
<span class="strut" style="height: 1.7936em; vertical-align: -0.686em;">
|
||||
<span class="mord">
|
||||
<span class="mopen nulldelimiter">
|
||||
<span class="mfrac">
|
||||
<span class="vlist-t vlist-t2">
|
||||
<span class="vlist-r">
|
||||
<span class="vlist" style="height: 1.1076em;">
|
||||
<span style="top: -2.314em;">
|
||||
<span class="pstrut" style="height: 3em;">
|
||||
<span class="mord">
|
||||
<span class="mord">
|
||||
#text "2"
|
||||
<span style="top: -3.23em;">
|
||||
<span class="pstrut" style="height: 3em;">
|
||||
<span class="frac-line" style="border-bottom-width: 0.04em;">
|
||||
<span style="top: -3.677em;">
|
||||
<span class="pstrut" style="height: 3em;">
|
||||
<span class="mord">
|
||||
<span class="mord mathnormal" style="margin-right: 0.0359em;">
|
||||
#text "π"
|
||||
<span class="vlist-s">
|
||||
#text ""
|
||||
<span class="vlist-r">
|
||||
<span class="vlist" style="height: 0.686em;">
|
||||
<span>
|
||||
<span class="mclose nulldelimiter">
|
||||
@@ -0,0 +1,9 @@
|
||||
<div class="_markdown_404681">
|
||||
<p>
|
||||
#text "Einstein wrote $E = mc^2$ inline."
|
||||
<p>
|
||||
#text "$$\n\\frac{\\partial \\mathbf{u}}{\\partial t} + (\\mathbf{u} \\cdot \\nabla)\\mathbf{u} = -\\frac{1}{\\rho}\\nabla p\n$$"
|
||||
<p>
|
||||
#text "Backslash inline (\\frac{1}{5}) and display:"
|
||||
<p>
|
||||
#text "[\\frac{\\pi}{4} < \\theta < \\frac{\\pi}{2}]"
|
||||
7
packages/client/ui-primitives/tests/fixtures/markdown-dom/raw-html-dropped.settled.txt
vendored
Normal file
7
packages/client/ui-primitives/tests/fixtures/markdown-dom/raw-html-dropped.settled.txt
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
<div class="_markdown_404681">
|
||||
#text "<script>globalThis.compromised = true</script>\n"
|
||||
<p>
|
||||
#text "Paragraph with inline <img src=\"x\" onerror=\"boom\"> html and <b>bold tag</b> kept literal?"
|
||||
#text "\n<div class=\"x\">\nhtml block content\n</div>\n"
|
||||
<p>
|
||||
#text "after"
|
||||
7
packages/client/ui-primitives/tests/fixtures/markdown-dom/raw-html-dropped.streaming.txt
vendored
Normal file
7
packages/client/ui-primitives/tests/fixtures/markdown-dom/raw-html-dropped.streaming.txt
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
<div class="_markdown_404681">
|
||||
#text "<script>globalThis.compromised = true</script>\n"
|
||||
<p>
|
||||
#text "Paragraph with inline <img src=\"x\" onerror=\"boom\"> html and <b>bold tag</b> kept literal?"
|
||||
#text "\n<div class=\"x\">\nhtml block content\n</div>\n"
|
||||
<p>
|
||||
#text "after"
|
||||
16
packages/client/ui-primitives/tests/fixtures/markdown-dom/reference-links-and-images.settled.txt
vendored
Normal file
16
packages/client/ui-primitives/tests/fixtures/markdown-dom/reference-links-and-images.settled.txt
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
<div class="_markdown_404681">
|
||||
<p>
|
||||
#text "A "
|
||||
<a href="https://example.com/ref" rel="noopener noreferrer" target="_blank">
|
||||
#text "full"
|
||||
#text " reference, a "
|
||||
<a href="https://example.com/collapsed" rel="noopener noreferrer" target="_blank">
|
||||
#text "collapsed"
|
||||
#text " one, and a "
|
||||
<a href="https://example.com/shortcut" rel="noopener noreferrer" target="_blank">
|
||||
#text "shortcut"
|
||||
#text " one."
|
||||
<p>
|
||||
#text "[missing full][nope], [missing collapsed][], ![missing image][gone]."
|
||||
<p>
|
||||
<img alt="ref image" class="_image_404681" decoding="async" loading="lazy" referrerpolicy="no-referrer" src="https://example.com/ref.png">
|
||||
@@ -0,0 +1,16 @@
|
||||
<div class="_markdown_404681">
|
||||
<p>
|
||||
#text "A "
|
||||
<a href="https://example.com/ref" rel="noopener noreferrer" target="_blank">
|
||||
#text "full"
|
||||
#text " reference, a "
|
||||
<a href="https://example.com/collapsed" rel="noopener noreferrer" target="_blank">
|
||||
#text "collapsed"
|
||||
#text " one, and a "
|
||||
<a href="https://example.com/shortcut" rel="noopener noreferrer" target="_blank">
|
||||
#text "shortcut"
|
||||
#text " one."
|
||||
<p>
|
||||
#text "[missing full][nope], [missing collapsed][], ![missing image][gone]."
|
||||
<p>
|
||||
<img alt="ref image" class="_image_404681" decoding="async" loading="lazy" referrerpolicy="no-referrer" src="https://example.com/ref.png">
|
||||
@@ -0,0 +1,8 @@
|
||||
<div class="_markdown_404681">
|
||||
<h2>
|
||||
#text "Streaming"
|
||||
<ul>
|
||||
<li>
|
||||
#text "first"
|
||||
<li>
|
||||
#text "**unfinished"
|
||||
@@ -0,0 +1,8 @@
|
||||
<div class="_markdown_404681">
|
||||
<h2>
|
||||
#text "Streaming"
|
||||
<ul>
|
||||
<li>
|
||||
#text "first"
|
||||
<li>
|
||||
#text "**unfinished"
|
||||
35
packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.settled.txt
vendored
Normal file
35
packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.settled.txt
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
<div class="_markdown_404681">
|
||||
<div class="_tableScroll_404681">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="text-align: left;">
|
||||
#text "Left"
|
||||
<th style="text-align: center;">
|
||||
#text "Center"
|
||||
<th style="text-align: right;">
|
||||
#text "Right"
|
||||
<th>
|
||||
#text "None"
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="text-align: left;">
|
||||
#text "a"
|
||||
<td style="text-align: center;">
|
||||
#text "b"
|
||||
<td style="text-align: right;">
|
||||
#text "c"
|
||||
<td>
|
||||
<code>
|
||||
#text "code"
|
||||
<tr>
|
||||
<td style="text-align: left;">
|
||||
<a href="https://example.com" rel="noopener noreferrer" target="_blank">
|
||||
#text "link"
|
||||
<td style="text-align: center;">
|
||||
<em>
|
||||
#text "em"
|
||||
<td style="text-align: right;">
|
||||
#text "1"
|
||||
<td>
|
||||
#text "2"
|
||||
35
packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.streaming.txt
vendored
Normal file
35
packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.streaming.txt
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
<div class="_markdown_404681">
|
||||
<div class="_tableScroll_404681">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="text-align: left;">
|
||||
#text "Left"
|
||||
<th style="text-align: center;">
|
||||
#text "Center"
|
||||
<th style="text-align: right;">
|
||||
#text "Right"
|
||||
<th>
|
||||
#text "None"
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="text-align: left;">
|
||||
#text "a"
|
||||
<td style="text-align: center;">
|
||||
#text "b"
|
||||
<td style="text-align: right;">
|
||||
#text "c"
|
||||
<td>
|
||||
<code>
|
||||
#text "code"
|
||||
<tr>
|
||||
<td style="text-align: left;">
|
||||
<a href="https://example.com" rel="noopener noreferrer" target="_blank">
|
||||
#text "link"
|
||||
<td style="text-align: center;">
|
||||
<em>
|
||||
#text "em"
|
||||
<td style="text-align: right;">
|
||||
#text "1"
|
||||
<td>
|
||||
#text "2"
|
||||
19
packages/client/ui-primitives/tests/fixtures/markdown-dom/task-lists.settled.txt
vendored
Normal file
19
packages/client/ui-primitives/tests/fixtures/markdown-dom/task-lists.settled.txt
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
<div class="_markdown_404681">
|
||||
<ul class="contains-task-list">
|
||||
<li class="task-list-item">
|
||||
<input checked="" disabled="" type="checkbox">
|
||||
#text " done with "
|
||||
<strong>
|
||||
#text "strong"
|
||||
<li class="task-list-item">
|
||||
<input disabled="" type="checkbox">
|
||||
#text " pending"
|
||||
<li>
|
||||
#text "plain sibling"
|
||||
<ol class="contains-task-list">
|
||||
<li class="task-list-item">
|
||||
<input checked="" disabled="" type="checkbox">
|
||||
#text " ordered done"
|
||||
<li class="task-list-item">
|
||||
<input disabled="" type="checkbox">
|
||||
#text " ordered pending"
|
||||
19
packages/client/ui-primitives/tests/fixtures/markdown-dom/task-lists.streaming.txt
vendored
Normal file
19
packages/client/ui-primitives/tests/fixtures/markdown-dom/task-lists.streaming.txt
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
<div class="_markdown_404681">
|
||||
<ul class="contains-task-list">
|
||||
<li class="task-list-item">
|
||||
<input checked="" disabled="" type="checkbox">
|
||||
#text " done with "
|
||||
<strong>
|
||||
#text "strong"
|
||||
<li class="task-list-item">
|
||||
<input disabled="" type="checkbox">
|
||||
#text " pending"
|
||||
<li>
|
||||
#text "plain sibling"
|
||||
<ol class="contains-task-list">
|
||||
<li class="task-list-item">
|
||||
<input checked="" disabled="" type="checkbox">
|
||||
#text " ordered done"
|
||||
<li class="task-list-item">
|
||||
<input disabled="" type="checkbox">
|
||||
#text " ordered pending"
|
||||
232
packages/client/ui-primitives/tests/markdown-dom-parity.spec.tsx
Normal file
232
packages/client/ui-primitives/tests/markdown-dom-parity.spec.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
// @vitest-environment jsdom
|
||||
// DOM-parity contract for MarkdownText: every corpus document's rendered DOM
|
||||
// is pinned as a file snapshot. The fixtures were recorded from the
|
||||
// react-markdown implementation this renderer replaced; the custom mdast
|
||||
// renderer must reproduce them byte-for-byte (after whitespace
|
||||
// normalization), so a fixture diff means a user-visible markdown style
|
||||
// change and must be reviewed as such — never re-record to silence a
|
||||
// refactor.
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
/**
|
||||
* Serialize rendered DOM deterministically: adjacent text nodes coalesced
|
||||
* (React renders adjacent string children as separate DOM text nodes while
|
||||
* hast merges them — invisible either way), whitespace-only runs dropped
|
||||
* outside `pre` (the markdown pipeline injects cosmetic newlines between
|
||||
* blocks that HTML rendering collapses), attributes sorted by name, children
|
||||
* indented for reviewable diffs.
|
||||
*/
|
||||
function serialize(node: Node, indent: string, inPre: boolean): string {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return ''
|
||||
const element = node as Element
|
||||
const attrs = [...element.attributes]
|
||||
.map(attr => `${attr.name}=${JSON.stringify(attr.value)}`)
|
||||
.sort()
|
||||
.join(' ')
|
||||
const open = attrs === '' ? element.tagName.toLowerCase() : `${element.tagName.toLowerCase()} ${attrs}`
|
||||
const nowInPre = inPre || element.tagName === 'PRE'
|
||||
return `${indent}<${open}>\n${serializeChildren(element, `${indent} `, nowInPre)}`
|
||||
}
|
||||
|
||||
function serializeChildren(element: Element, indent: string, inPre: boolean): string {
|
||||
let out = ''
|
||||
let textRun = ''
|
||||
const flush = (): void => {
|
||||
if (textRun !== '' && (inPre || textRun.trim() !== '')) {
|
||||
out += `${indent}#text ${JSON.stringify(textRun)}\n`
|
||||
}
|
||||
textRun = ''
|
||||
}
|
||||
for (const child of element.childNodes) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
textRun += child.textContent ?? ''
|
||||
continue
|
||||
}
|
||||
flush()
|
||||
out += serialize(child, indent, inPre)
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
|
||||
/** Render one markdown source through MarkdownText and serialize the DOM. */
|
||||
function renderCase(text: string, streaming: boolean): string {
|
||||
const { container, unmount } = render(<MarkdownText text={text} streaming={streaming} />)
|
||||
const out = [...container.childNodes].map(child => serialize(child, '', false)).join('')
|
||||
unmount()
|
||||
return out
|
||||
}
|
||||
|
||||
const CORPUS: Record<string, string> = {
|
||||
'headings-and-paragraphs': [
|
||||
'# H1 with `code`',
|
||||
'',
|
||||
'## H2',
|
||||
'',
|
||||
'### H3',
|
||||
'',
|
||||
'#### H4',
|
||||
'',
|
||||
'##### H5',
|
||||
'',
|
||||
'###### H6',
|
||||
'',
|
||||
'Paragraph one with **strong**, *emphasis*, ~~strike~~, and `inline`.',
|
||||
'',
|
||||
'Setext title',
|
||||
'=========',
|
||||
'',
|
||||
'Second setext',
|
||||
'---------',
|
||||
].join('\n'),
|
||||
'heading-tight-against-list': '#### Small heading\n\n- one\n- two\n\n##### Next\n\n1. a\n2. b',
|
||||
'hard-breaks-and-hr': 'two-space break \nafter break\n\nbackslash break\\\nafter backslash\n\n---\n\ntail',
|
||||
'blockquote-nested': '> level one\n> still one\n>\n> > nested\n>\n> - quoted list\n\nafter',
|
||||
'lists-tight-loose-nested': [
|
||||
'- tight one',
|
||||
'- tight two',
|
||||
' - child',
|
||||
'',
|
||||
'1. first',
|
||||
'2. second',
|
||||
'',
|
||||
'3. ordered with start',
|
||||
'4. next',
|
||||
'',
|
||||
'- loose item one',
|
||||
'',
|
||||
'- loose item two',
|
||||
'',
|
||||
' second paragraph of loose item',
|
||||
'',
|
||||
'- item with nested blocks',
|
||||
'',
|
||||
' ```',
|
||||
' fenced inside list',
|
||||
' ```',
|
||||
].join('\n'),
|
||||
'task-lists': '- [x] done with **strong**\n- [ ] pending\n- plain sibling\n\n1. [x] ordered done\n2. [ ] ordered pending',
|
||||
'table-with-alignment': [
|
||||
'| Left | Center | Right | None |',
|
||||
'| :--- | :---: | ---: | --- |',
|
||||
'| a | b | c | `code` |',
|
||||
'| [link](https://example.com) | *em* | 1 | 2 |',
|
||||
].join('\n'),
|
||||
'code-fences': [
|
||||
'```ts',
|
||||
'const answer: number = 42',
|
||||
'```',
|
||||
'',
|
||||
'```',
|
||||
'no language',
|
||||
'```',
|
||||
'',
|
||||
'```unknown-lang',
|
||||
'plain fallback',
|
||||
'```',
|
||||
'',
|
||||
'```ts some=meta',
|
||||
'const withMeta = true',
|
||||
'```',
|
||||
'',
|
||||
'```',
|
||||
'```',
|
||||
'',
|
||||
' indented code block',
|
||||
' second line',
|
||||
].join('\n'),
|
||||
'inline-code-with-newline': 'Spans `a\nb` across a line.',
|
||||
'links-and-autolinks': [
|
||||
'[https ok](https://example.com "with title") and [mailto ok](mailto:dev@example.com).',
|
||||
'',
|
||||
'[relative dropped](/settings) and [js dropped](javascript:alert(1)) and [upper kept](HTTPS://example.com).',
|
||||
'',
|
||||
'<https://deepseek.com> and bare autolink https://autolink.example.com literal.',
|
||||
'',
|
||||
'[spaces encoded](https://example.com/a b)',
|
||||
].join('\n'),
|
||||
'images': [
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
' and inline ) end.',
|
||||
'',
|
||||
'',
|
||||
].join('\n'),
|
||||
'reference-links-and-images': [
|
||||
'A [full][ref] reference, a [collapsed][] one, and a [shortcut] one.',
|
||||
'',
|
||||
'[missing full][nope], [missing collapsed][], ![missing image][gone].',
|
||||
'',
|
||||
'![ref image][imgref]',
|
||||
'',
|
||||
'[ref]: https://example.com/ref "ref title"',
|
||||
'[collapsed]: https://example.com/collapsed',
|
||||
'[shortcut]: https://example.com/shortcut',
|
||||
'[imgref]: https://example.com/ref.png',
|
||||
].join('\n'),
|
||||
'footnotes': [
|
||||
'First use[^a] and reuse[^a] and another[^b].',
|
||||
'',
|
||||
'[^a]: Footnote a body with [link](https://example.com).',
|
||||
'',
|
||||
'[^b]: Footnote b first paragraph.',
|
||||
'',
|
||||
' Second paragraph of b.',
|
||||
].join('\n'),
|
||||
'raw-html-dropped': [
|
||||
'<script>globalThis.compromised = true</script>',
|
||||
'',
|
||||
'Paragraph with inline <img src="x" onerror="boom"> html and <b>bold tag</b> kept literal?',
|
||||
'',
|
||||
'<div class="x">',
|
||||
'html block content',
|
||||
'</div>',
|
||||
'',
|
||||
'after',
|
||||
].join('\n'),
|
||||
'entities-and-escapes': 'AT&T, 3 < 4, \\*not em\\*, backslash \\\\ literal, © entity.',
|
||||
'math-inline-and-display': [
|
||||
'Einstein wrote $E = mc^2$ inline.',
|
||||
'',
|
||||
'$$',
|
||||
'\\frac{\\partial \\mathbf{u}}{\\partial t} + (\\mathbf{u} \\cdot \\nabla)\\mathbf{u} = -\\frac{1}{\\rho}\\nabla p',
|
||||
'$$',
|
||||
'',
|
||||
'Backslash inline \\(\\frac{1}{5}\\) and display:',
|
||||
'',
|
||||
'\\[\\frac{\\pi}{4} < \\theta < \\frac{\\pi}{2}\\]',
|
||||
].join('\n'),
|
||||
'math-edge-cases': [
|
||||
'Trusted commands stay off: $\\href{javascript:alert(1)}{unsafe}$.',
|
||||
'',
|
||||
'Unbalanced errors render the error arm: $\\frac{$',
|
||||
'',
|
||||
'| Symbol | Value |',
|
||||
'| --- | --- |',
|
||||
'| $\\theta$ | \\(\\frac{1}{5}\\) |',
|
||||
'',
|
||||
'```math',
|
||||
'\\sqrt{2}',
|
||||
'```',
|
||||
].join('\n'),
|
||||
'gfm-strikethrough-and-literals': 'Mixed ~~gone~~ text with www.example.com literal and user@example.com email.',
|
||||
'definition-only': '[unused]: https://example.com/unused',
|
||||
'streaming-typical-partial': '## Streaming\n\n- first\n- **unfinished',
|
||||
}
|
||||
|
||||
describe('MarkdownText DOM parity fixtures', () => {
|
||||
for (const [name, text] of Object.entries(CORPUS)) {
|
||||
it(`settled: ${name}`, async () => {
|
||||
await expect(renderCase(text, false)).toMatchFileSnapshot(`./fixtures/markdown-dom/${name}.settled.txt`)
|
||||
})
|
||||
it(`streaming: ${name}`, async () => {
|
||||
await expect(renderCase(text, true)).toMatchFileSnapshot(`./fixtures/markdown-dom/${name}.streaming.txt`)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,419 @@
|
||||
// @vitest-environment jsdom
|
||||
// Incremental streaming behavior: a MarkdownText kept mounted across
|
||||
// append-only rerenders must show, at every step, exactly the DOM a fresh
|
||||
// mount of the same prefix shows, while reusing the frozen blocks' DOM nodes
|
||||
// instead of remounting them.
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { Root, RootContent } from 'mdast'
|
||||
import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IncrementalMarkdownParser } from '../src/markdown/incremental.ts'
|
||||
import { parseGfm } from '../src/markdown/parse.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
/** A many-block document exercising every freeze-sensitive construct. */
|
||||
const STREAM_DOC = [
|
||||
'# Title',
|
||||
'',
|
||||
'First paragraph with **strong** and `code`.',
|
||||
'',
|
||||
'- list item one',
|
||||
'- list item two',
|
||||
'',
|
||||
' continuation of item two',
|
||||
'',
|
||||
'Setext heading',
|
||||
'===',
|
||||
'',
|
||||
'| a | b |',
|
||||
'| --- | --- |',
|
||||
'| 1 | 2 |',
|
||||
'',
|
||||
'```ts',
|
||||
'const x = 1',
|
||||
'',
|
||||
'still inside the fence',
|
||||
'```',
|
||||
'',
|
||||
'> quote with lazy',
|
||||
'continuation line',
|
||||
'',
|
||||
'Uses a footnote[^n] twice[^n].',
|
||||
'',
|
||||
'[^n]: The footnote body.',
|
||||
'',
|
||||
'Closing paragraph after enough blocks to freeze everything above.',
|
||||
'',
|
||||
'One more tail block.',
|
||||
].join('\n')
|
||||
|
||||
describe('incremental streaming rendering', () => {
|
||||
for (const chunkSize of [1, 3, 7, 16]) {
|
||||
it(`matches a fresh render at every prefix (chunk=${chunkSize})`, () => {
|
||||
const live = render(<MarkdownText text="" streaming />)
|
||||
for (let end = chunkSize; end < STREAM_DOC.length + chunkSize; end += chunkSize) {
|
||||
const prefix = STREAM_DOC.slice(0, Math.min(end, STREAM_DOC.length))
|
||||
live.rerender(<MarkdownText text={prefix} streaming />)
|
||||
const fresh = render(<MarkdownText text={prefix} streaming />)
|
||||
expect(live.container.innerHTML).toBe(fresh.container.innerHTML)
|
||||
fresh.unmount()
|
||||
}
|
||||
live.unmount()
|
||||
})
|
||||
}
|
||||
|
||||
it('keeps frozen block DOM nodes across freezes instead of remounting', () => {
|
||||
const paragraphs = Array.from({ length: 8 }, (_, i) => `Paragraph number ${i}.`)
|
||||
const first = `${paragraphs[0]}\n\n`
|
||||
const live = render(<MarkdownText text={first} streaming />)
|
||||
const firstBlock = live.container.querySelector('p')
|
||||
expect(firstBlock?.textContent).toBe(paragraphs[0])
|
||||
live.rerender(<MarkdownText text={paragraphs.join('\n\n')} streaming />)
|
||||
// Same DOM node instance: the block kept its key across the freeze boundary.
|
||||
expect(live.container.querySelector('p')).toBe(firstBlock)
|
||||
expect(live.container.querySelectorAll('p')).toHaveLength(paragraphs.length)
|
||||
live.unmount()
|
||||
})
|
||||
|
||||
it('recovers when the text diverges instead of appending', () => {
|
||||
const live = render(<MarkdownText text={'alpha\n\nbeta\n\ngamma\n\ndelta'} streaming />)
|
||||
live.rerender(<MarkdownText text={'totally\n\ndifferent\n\ndocument'} streaming />)
|
||||
const fresh = render(<MarkdownText text={'totally\n\ndifferent\n\ndocument'} streaming />)
|
||||
expect(live.container.innerHTML).toBe(fresh.container.innerHTML)
|
||||
live.unmount()
|
||||
fresh.unmount()
|
||||
})
|
||||
|
||||
it('drops the streaming cache when the copy labels change identity', () => {
|
||||
const doc = ['```ts', 'const a = 1', '```', '', 'p1', '', 'p2', '', 'p3'].join('\n')
|
||||
const live = render(<MarkdownText text={doc} streaming codeLabels={{ copyLabel: 'Copy' }} />)
|
||||
expect([...live.container.querySelectorAll('button')].map(b => b.textContent)).toEqual(['Copy'])
|
||||
live.rerender(<MarkdownText text={doc} streaming codeLabels={{ copyLabel: 'Kopieren' }} />)
|
||||
expect([...live.container.querySelectorAll('button')].map(b => b.textContent)).toEqual(['Kopieren'])
|
||||
live.unmount()
|
||||
})
|
||||
|
||||
it('settles into the full math-enabled render after streaming', () => {
|
||||
const doc = 'Value $E = mc^2$ inline.\n\nSecond.\n\nThird.\n\nFourth.'
|
||||
const live = render(<MarkdownText text={doc} streaming />)
|
||||
expect(live.container.querySelector('.katex')).toBeNull()
|
||||
live.rerender(<MarkdownText text={doc} />)
|
||||
const settled = render(<MarkdownText text={doc} />)
|
||||
expect(live.container.innerHTML).toBe(settled.container.innerHTML)
|
||||
expect(live.container.querySelector('.katex')).not.toBeNull()
|
||||
live.unmount()
|
||||
settled.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('incremental parsing is actually in effect', () => {
|
||||
it('hands the grammar only the source tail once blocks freeze', () => {
|
||||
const calls: string[] = []
|
||||
const recording = (text: string): Root => {
|
||||
calls.push(text)
|
||||
return parseGfm(text)
|
||||
}
|
||||
const parser = new IncrementalMarkdownParser(recording)
|
||||
const paragraphs = Array.from({ length: 40 }, (_, i) => `Paragraph number ${i} with some words.`)
|
||||
let text = ''
|
||||
for (const paragraph of paragraphs) {
|
||||
text += `${paragraph}\n\n`
|
||||
parser.update(text)
|
||||
}
|
||||
expect(text.length).toBeGreaterThan(1500)
|
||||
// Warm-up aside, every parse sees only the unstable tail: bounded by a
|
||||
// few paragraphs, not the growing document.
|
||||
const steady = calls.slice(5)
|
||||
expect(Math.max(...steady.map(call => call.length))).toBeLessThan(200)
|
||||
expect(steady.every(call => !call.includes('Paragraph number 0 '))).toBe(true)
|
||||
// Cumulative parsed bytes stay linear in the document; full re-parsing
|
||||
// would have accumulated ~40/2 times the document length here.
|
||||
const totalParsed = calls.reduce((sum, call) => sum + call.length, 0)
|
||||
expect(totalParsed).toBeLessThan(text.length * 5)
|
||||
})
|
||||
|
||||
it('shows the documented streaming fingerprint: a definition frozen earlier no longer resolves a new reference, and settling heals it', () => {
|
||||
const doc = [
|
||||
'[ref]: https://example.com/target',
|
||||
'',
|
||||
'Paragraph one keeps the definition company.',
|
||||
'',
|
||||
'Paragraph two pushes the freeze boundary.',
|
||||
'',
|
||||
'Paragraph three freezes the definition out.',
|
||||
'',
|
||||
'See [the link][ref] for details.',
|
||||
].join('\n')
|
||||
const head = doc.slice(0, doc.indexOf('See'))
|
||||
const live = render(<MarkdownText text={head} streaming />)
|
||||
live.rerender(<MarkdownText text={doc} streaming />)
|
||||
// The tail re-parse cannot see the frozen definition, so the reference
|
||||
// stays literal — the direct observable that the whole text was NOT
|
||||
// re-parsed (a one-shot mount of the same text resolves it).
|
||||
expect(live.container.querySelector('a')).toBeNull()
|
||||
expect(live.container.textContent).toContain('[the link][ref]')
|
||||
const fresh = render(<MarkdownText text={doc} streaming />)
|
||||
expect(fresh.container.querySelector('a')?.getAttribute('href')).toBe('https://example.com/target')
|
||||
fresh.unmount()
|
||||
// The settled swap re-parses everything and heals the deviation.
|
||||
live.rerender(<MarkdownText text={doc} />)
|
||||
expect(live.container.querySelector('a')?.getAttribute('href')).toBe('https://example.com/target')
|
||||
live.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('freeze dynamics around frontier-sensitive constructs', () => {
|
||||
it('an unclosed fence pins the tail: nothing freezes until it closes', () => {
|
||||
const parser = new IncrementalMarkdownParser(parseGfm)
|
||||
let text = 'p1.\n\np2.\n\np3.\n\n```ts\n'
|
||||
const opened = parser.update(text)
|
||||
const frozenAtOpen = opened.frozen.length
|
||||
expect(opened.tail[opened.tail.length - 1]?.node.type).toBe('code')
|
||||
for (const line of ['const a = 1\n', '\n', 'looks like a paragraph\n', '- looks like a list\n']) {
|
||||
text += line
|
||||
const grown = parser.update(text)
|
||||
// The fence swallows everything appended, so the block census cannot
|
||||
// grow and the freeze boundary must hold still.
|
||||
expect(grown.frozen.length).toBe(frozenAtOpen)
|
||||
expect(grown.tail[grown.tail.length - 1]?.node.type).toBe('code')
|
||||
}
|
||||
text += '```\n\nafter one.\n\nafter two.\n'
|
||||
const closed = parser.update(text)
|
||||
expect(closed.frozen.length).toBeGreaterThan(frozenAtOpen)
|
||||
const frozenCode = closed.frozen.find(block => block.node.type === 'code')?.node
|
||||
expect(frozenCode?.type === 'code' && frozenCode.value).toContain('looks like a list')
|
||||
})
|
||||
|
||||
it('a list can keep extending across blank lines until it freezes whole', () => {
|
||||
const parser = new IncrementalMarkdownParser(parseGfm)
|
||||
let text = 'intro.\n\nsecond.\n\nthird.\n\n- item a\n- item b\n'
|
||||
const before = parser.update(text)
|
||||
const frozenBefore = before.frozen.length
|
||||
text += '\n- item c\n'
|
||||
const extended = parser.update(text)
|
||||
expect(extended.frozen.length).toBe(frozenBefore)
|
||||
const tailList = extended.tail[extended.tail.length - 1]?.node
|
||||
expect(tailList?.type === 'list' && tailList.children).toHaveLength(3)
|
||||
text += '\nafter.\n\nmore.\n\nend.\n'
|
||||
const after = parser.update(text)
|
||||
const frozenList = after.frozen.find(block => block.node.type === 'list')?.node
|
||||
expect(frozenList?.type === 'list' && frozenList.children).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('keeps every previously frozen key as a stable prefix across the stream', () => {
|
||||
const parser = new IncrementalMarkdownParser(parseGfm)
|
||||
let previous: readonly number[] = []
|
||||
for (let end = 7; end < STREAM_DOC.length + 7; end += 7) {
|
||||
const { frozen } = parser.update(STREAM_DOC.slice(0, Math.min(end, STREAM_DOC.length)))
|
||||
const keys = frozen.map(block => block.key)
|
||||
expect(keys.slice(0, previous.length)).toEqual(previous)
|
||||
previous = keys
|
||||
}
|
||||
expect(previous.length).toBeGreaterThan(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('multibyte content', () => {
|
||||
const CJK_DOC = [
|
||||
'# 标题 🎉',
|
||||
'',
|
||||
'这是一段包含 **加粗**、`行内代码` 与表情 😀🚀 的中文段落。',
|
||||
'',
|
||||
'- 列表项一 ✅',
|
||||
'- 列表项二',
|
||||
'',
|
||||
'> 引用一行,带表情 🐟',
|
||||
'',
|
||||
'```',
|
||||
'中文代码 🎯',
|
||||
'```',
|
||||
'',
|
||||
'| 键 | 值 |',
|
||||
'| --- | --- |',
|
||||
'| 甲 | 乙 |',
|
||||
'',
|
||||
'结尾段落,足够多的块让前面全部冻结。🌊',
|
||||
].join('\n')
|
||||
|
||||
it('code-unit chunking (splitting surrogate pairs mid-stream) matches fresh renders', () => {
|
||||
const live = render(<MarkdownText text="" streaming />)
|
||||
for (let end = 1; end < CJK_DOC.length + 1; end += 1) {
|
||||
const prefix = CJK_DOC.slice(0, Math.min(end, CJK_DOC.length))
|
||||
live.rerender(<MarkdownText text={prefix} streaming />)
|
||||
const fresh = render(<MarkdownText text={prefix} streaming />)
|
||||
expect(live.container.innerHTML).toBe(fresh.container.innerHTML)
|
||||
fresh.unmount()
|
||||
}
|
||||
live.unmount()
|
||||
})
|
||||
|
||||
it('freeze-cut offsets agree with one-shot parse offsets on astral content', () => {
|
||||
const parser = new IncrementalMarkdownParser(parseGfm)
|
||||
let result = parser.update(CJK_DOC.slice(0, 3))
|
||||
for (let end = 6; end < CJK_DOC.length + 3; end += 3) {
|
||||
result = parser.update(CJK_DOC.slice(0, Math.min(end, CJK_DOC.length)))
|
||||
}
|
||||
const oneShot = parseGfm(CJK_DOC).children.map(node => node.position?.start.offset)
|
||||
expect([...result.frozen, ...result.tail].map(block => block.key)).toEqual(oneShot)
|
||||
expect(result.frozen.length).toBeGreaterThan(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('streaming composition across freezes', () => {
|
||||
it('continues footnote numbering from frozen references and lists all definitions', () => {
|
||||
const doc = [
|
||||
'Alpha uses a footnote[^a].',
|
||||
'',
|
||||
'[^a]: First note body.',
|
||||
'',
|
||||
'Filler one.',
|
||||
'',
|
||||
'Filler two.',
|
||||
'',
|
||||
'Filler three.',
|
||||
'',
|
||||
'Beta uses another[^b].',
|
||||
'',
|
||||
'[^b]: Second note body.',
|
||||
].join('\n')
|
||||
const head = doc.slice(0, doc.indexOf('Beta'))
|
||||
const live = render(<MarkdownText text={head} streaming />)
|
||||
live.rerender(<MarkdownText text={doc} streaming />)
|
||||
expect([...live.container.querySelectorAll('p sup')].map(sup => sup.textContent)).toEqual(['1', '2'])
|
||||
expect([...live.container.querySelectorAll('section.footnotes li')].map(li => li.id))
|
||||
.toEqual(['user-content-fn-a', 'user-content-fn-b'])
|
||||
expect(live.container.querySelector('section.footnotes')?.textContent).toContain('First note body. ↩')
|
||||
const fresh = render(<MarkdownText text={doc} streaming />)
|
||||
expect(live.container.innerHTML).toBe(fresh.container.innerHTML)
|
||||
fresh.unmount()
|
||||
live.unmount()
|
||||
})
|
||||
|
||||
it('keeps every frozen block DOM node through the rest of the stream', () => {
|
||||
const paragraphs = Array.from({ length: 12 }, (_, i) => `Stable paragraph ${i}.`)
|
||||
const half = `${paragraphs.slice(0, 6).join('\n\n')}\n\n`
|
||||
const live = render(<MarkdownText text={half} streaming />)
|
||||
const captured = [...live.container.querySelectorAll('p')]
|
||||
expect(captured.length).toBe(6)
|
||||
let text = half
|
||||
for (const paragraph of paragraphs.slice(6)) {
|
||||
text += `${paragraph}\n\n`
|
||||
live.rerender(<MarkdownText text={text} streaming />)
|
||||
}
|
||||
const finalNodes = [...live.container.querySelectorAll('p')]
|
||||
expect(finalNodes.slice(0, 6)).toEqual(captured)
|
||||
expect(finalNodes).toHaveLength(12)
|
||||
live.unmount()
|
||||
})
|
||||
|
||||
it('renders an empty document for definition-only streams, including trailing blank lines', () => {
|
||||
const doc = '[a]: https://example.com/1\n\n[b]: https://example.com/2\n\n[c]: https://example.com/3\n\n[d]: https://example.com/4'
|
||||
const live = render(<MarkdownText text={doc.slice(0, 30)} streaming />)
|
||||
live.rerender(<MarkdownText text={doc} streaming />)
|
||||
live.rerender(<MarkdownText text={`${doc}\n\n\n`} streaming />)
|
||||
const fresh = render(<MarkdownText text={`${doc}\n\n\n`} streaming />)
|
||||
expect(live.container.innerHTML).toBe(fresh.container.innerHTML)
|
||||
expect(live.container.querySelector('div')?.childNodes).toHaveLength(0)
|
||||
fresh.unmount()
|
||||
live.unmount()
|
||||
})
|
||||
|
||||
it('survives streaming → settled → streaming prop flips with a fresh incremental state', () => {
|
||||
const live = render(<MarkdownText text={'a.\n\nb.'} streaming />)
|
||||
live.rerender(<MarkdownText text={'a.\n\nb.'} />)
|
||||
const settled = render(<MarkdownText text={'a.\n\nb.'} />)
|
||||
expect(live.container.innerHTML).toBe(settled.container.innerHTML)
|
||||
settled.unmount()
|
||||
live.rerender(<MarkdownText text={'a.\n\nb.\n\nc.\n\nd.\n\ne.'} streaming />)
|
||||
const fresh = render(<MarkdownText text={'a.\n\nb.\n\nc.\n\nd.\n\ne.'} streaming />)
|
||||
expect(live.container.innerHTML).toBe(fresh.container.innerHTML)
|
||||
fresh.unmount()
|
||||
live.unmount()
|
||||
})
|
||||
|
||||
it('matches fresh renders under irregular deterministic chunk sizes', () => {
|
||||
let seed = 42
|
||||
const nextSize = (): number => {
|
||||
seed = (seed * 1103515245 + 12345) % 2147483648
|
||||
return 1 + (seed % 13)
|
||||
}
|
||||
const live = render(<MarkdownText text="" streaming />)
|
||||
let end = 0
|
||||
while (end < STREAM_DOC.length) {
|
||||
end = Math.min(end + nextSize(), STREAM_DOC.length)
|
||||
const prefix = STREAM_DOC.slice(0, end)
|
||||
live.rerender(<MarkdownText text={prefix} streaming />)
|
||||
const fresh = render(<MarkdownText text={prefix} streaming />)
|
||||
expect(live.container.innerHTML).toBe(fresh.container.innerHTML)
|
||||
fresh.unmount()
|
||||
}
|
||||
live.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('IncrementalMarkdownParser', () => {
|
||||
it('freezes all but the trailing two blocks and keeps freezing as blocks appear', () => {
|
||||
const parser = new IncrementalMarkdownParser(parseGfm)
|
||||
const first = parser.update('a\n\nb\n\nc\n\nd\n\ne')
|
||||
expect(first.frozen.map(b => b.node.type)).toEqual(['paragraph', 'paragraph', 'paragraph'])
|
||||
expect(first.tail).toHaveLength(2)
|
||||
const second = parser.update('a\n\nb\n\nc\n\nd\n\ne\n\nf\n\ng')
|
||||
expect(second.frozen).toHaveLength(5)
|
||||
expect(second.tail).toHaveLength(2)
|
||||
// Previously returned frozen entries keep their identity and keys.
|
||||
expect(second.frozen.slice(0, 3)).toEqual(first.frozen)
|
||||
expect(second.generation).toBe(first.generation)
|
||||
})
|
||||
|
||||
it('holds every block in the tail until more than two exist', () => {
|
||||
const parser = new IncrementalMarkdownParser(parseGfm)
|
||||
const result = parser.update('only\n\ntwo blocks')
|
||||
expect(result.frozen).toHaveLength(0)
|
||||
expect(result.tail).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('returns the cached result for identical input', () => {
|
||||
const parser = new IncrementalMarkdownParser(parseGfm)
|
||||
const first = parser.update('a\n\nb\n\nc')
|
||||
expect(parser.update('a\n\nb\n\nc')).toBe(first)
|
||||
})
|
||||
|
||||
it('bumps the generation and discards frozen blocks on non-append input', () => {
|
||||
const parser = new IncrementalMarkdownParser(parseGfm)
|
||||
const before = parser.update('a\n\nb\n\nc\n\nd')
|
||||
expect(before.frozen.length).toBeGreaterThan(0)
|
||||
const after = parser.update('different')
|
||||
expect(after.generation).toBe(before.generation + 1)
|
||||
expect(after.frozen).toHaveLength(0)
|
||||
expect(after.tail.map(b => b.node.type)).toEqual(['paragraph'])
|
||||
})
|
||||
|
||||
it('keys blocks by absolute source offset across freezes', () => {
|
||||
const doc = 'aaa\n\nbbb\n\nccc\n\nddd\n\neee'
|
||||
const parser = new IncrementalMarkdownParser(parseGfm)
|
||||
const grown = parser.update(doc)
|
||||
const oneShotKeys = parseGfm(doc).children.map(node => node.position?.start.offset)
|
||||
expect([...grown.frozen, ...grown.tail].map(b => b.key)).toEqual(oneShotKeys)
|
||||
})
|
||||
|
||||
it('never freezes under a grammar that omits positions', () => {
|
||||
const bare = (text: string): Root => {
|
||||
const root = parseGfm(text)
|
||||
const strip = (nodes: RootContent[]): void => {
|
||||
for (const node of nodes) {
|
||||
delete node.position
|
||||
if ('children' in node) strip(node.children)
|
||||
}
|
||||
}
|
||||
strip(root.children)
|
||||
return root
|
||||
}
|
||||
const parser = new IncrementalMarkdownParser(bare)
|
||||
const result = parser.update('a\n\nb\n\nc\n\nd\n\ne')
|
||||
expect(result.frozen).toHaveLength(0)
|
||||
expect(result.tail).toHaveLength(5)
|
||||
// Fallback keys stay unique per sibling.
|
||||
expect(new Set(result.tail.map(b => b.key)).size).toBe(5)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,225 @@
|
||||
// @vitest-environment jsdom
|
||||
// Branch coverage for the mdast renderer that real parses cannot reach: the
|
||||
// grammar only emits references whose definitions exist, always stamps
|
||||
// positions and align arrays, and never emits bare list items — but the
|
||||
// renderer is a pure function over mdast, so hand-built trees exercise its
|
||||
// defensive arms directly.
|
||||
import { StrictMode } from 'react'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type * as Md from 'mdast'
|
||||
import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import {
|
||||
collectReferenceTargets, createReferenceTargets, renderBlocks, renderFootnoteSection,
|
||||
} from '../src/markdown/render.tsx'
|
||||
import type { MarkdownRenderContext } from '../src/markdown/render.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function makeContext(): MarkdownRenderContext {
|
||||
return {
|
||||
streaming: false,
|
||||
codeLabels: undefined,
|
||||
targets: createReferenceTargets(),
|
||||
footnoteOrder: [],
|
||||
footnoteCounts: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
function renderNodes(nodes: Md.RootContent[], context = makeContext()): HTMLElement {
|
||||
const { container } = render(
|
||||
<div>{renderBlocks(nodes.map((node, key) => ({ node, key })), context)}</div>,
|
||||
)
|
||||
return container
|
||||
}
|
||||
|
||||
const text = (value: string): Md.Text => ({ type: 'text', value })
|
||||
|
||||
describe('renderBlocks over hand-built trees', () => {
|
||||
it('reverts unresolved references to their bracketed source', () => {
|
||||
const container = renderNodes([
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{ type: 'linkReference', identifier: 'a', referenceType: 'shortcut', children: [text('one')] },
|
||||
{ type: 'linkReference', identifier: 'b', referenceType: 'collapsed', children: [text('two')] },
|
||||
{ type: 'linkReference', identifier: 'c', label: 'C', referenceType: 'full', children: [text('three')] },
|
||||
{ type: 'imageReference', identifier: 'd', referenceType: 'full', alt: 'pic' },
|
||||
{ type: 'imageReference', identifier: 'e', referenceType: 'shortcut', alt: null },
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(container.textContent).toBe('[one][two][][three][C]![pic][d]![]')
|
||||
expect(container.querySelector('a')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the first definition when identifiers repeat', () => {
|
||||
const targets = createReferenceTargets()
|
||||
collectReferenceTargets([
|
||||
{ type: 'definition', identifier: 'dup', url: 'https://example.com/first' },
|
||||
{ type: 'definition', identifier: 'dup', url: 'https://example.com/second' },
|
||||
{ type: 'footnoteDefinition', identifier: 'fn', children: [] },
|
||||
{ type: 'footnoteDefinition', identifier: 'fn', children: [{ type: 'paragraph', children: [text('late')] }] },
|
||||
], targets)
|
||||
expect(targets.definitions.get('DUP')?.url).toBe('https://example.com/first')
|
||||
expect(targets.footnotes.get('FN')?.children).toEqual([])
|
||||
})
|
||||
|
||||
it('renders a bare list item, computing looseness from the item itself', () => {
|
||||
const item: Md.ListItem = {
|
||||
type: 'listItem',
|
||||
spread: null,
|
||||
children: [
|
||||
{ type: 'paragraph', children: [text('alpha')] },
|
||||
{ type: 'paragraph', children: [text('beta')] },
|
||||
],
|
||||
}
|
||||
const container = renderNodes([item])
|
||||
// Two block children make the parentless item loose: paragraphs stay wrapped.
|
||||
expect([...container.querySelectorAll('li > p')].map(p => p.textContent)).toEqual(['alpha', 'beta'])
|
||||
})
|
||||
|
||||
it('renders spread-null lists and align-less tables', () => {
|
||||
const container = renderNodes([
|
||||
{
|
||||
type: 'list',
|
||||
ordered: false,
|
||||
spread: null,
|
||||
children: [{ type: 'listItem', spread: null, children: [{ type: 'paragraph', children: [text('solo')] }] }],
|
||||
},
|
||||
{
|
||||
type: 'table',
|
||||
children: [
|
||||
{ type: 'tableRow', children: [{ type: 'tableCell', children: [text('h')] }] },
|
||||
{ type: 'tableRow', children: [{ type: 'tableCell', children: [text('short')] }] },
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(container.querySelector('li')?.textContent).toBe('solo')
|
||||
expect(container.querySelector('th')?.getAttribute('style')).toBeNull()
|
||||
expect(container.querySelector('td')?.textContent).toBe('short')
|
||||
})
|
||||
|
||||
it('pads rows against the alignment width with empty cells', () => {
|
||||
const container = renderNodes([
|
||||
{
|
||||
type: 'table',
|
||||
align: ['left', 'right'],
|
||||
children: [
|
||||
{ type: 'tableRow', children: [{ type: 'tableCell', children: [text('only')] }] },
|
||||
],
|
||||
},
|
||||
])
|
||||
const cells = [...container.querySelectorAll('th')]
|
||||
expect(cells).toHaveLength(2)
|
||||
expect(cells[1]?.textContent).toBe('')
|
||||
})
|
||||
|
||||
it('renders a checked item without any content as a bare checkbox', () => {
|
||||
const container = renderNodes([
|
||||
{
|
||||
type: 'list',
|
||||
ordered: false,
|
||||
children: [
|
||||
{ type: 'listItem', checked: true, children: [] },
|
||||
{ type: 'listItem', checked: false, children: [{ type: 'paragraph', children: [] }] },
|
||||
],
|
||||
},
|
||||
])
|
||||
const items = [...container.querySelectorAll('li.task-list-item')]
|
||||
expect(items).toHaveLength(2)
|
||||
for (const item of items) {
|
||||
expect(item.querySelector('input[type="checkbox"]')).not.toBeNull()
|
||||
expect(item.textContent?.trim()).toBe('')
|
||||
}
|
||||
})
|
||||
|
||||
it('renders images with a null alt as an empty alt attribute', () => {
|
||||
const targets = createReferenceTargets()
|
||||
targets.definitions.set('R', { type: 'definition', identifier: 'r', url: 'https://example.com/r.png' })
|
||||
const container = renderNodes([
|
||||
{ type: 'paragraph', children: [{ type: 'image', url: 'https://example.com/x.png', alt: null }] },
|
||||
{ type: 'paragraph', children: [{ type: 'imageReference', identifier: 'r', referenceType: 'full', alt: null }] },
|
||||
], { ...makeContext(), targets })
|
||||
const images = [...container.querySelectorAll('img')]
|
||||
expect(images.map(image => image.getAttribute('alt'))).toEqual(['', ''])
|
||||
})
|
||||
|
||||
it('drops a definition nested in a list item without leaving a separator behind', () => {
|
||||
const container = renderNodes([
|
||||
{
|
||||
type: 'list',
|
||||
ordered: true,
|
||||
start: 3,
|
||||
children: [{
|
||||
type: 'listItem',
|
||||
children: [
|
||||
{ type: 'paragraph', children: [text('body')] },
|
||||
{ type: 'definition', identifier: 'x', url: 'https://example.com' },
|
||||
],
|
||||
}],
|
||||
},
|
||||
])
|
||||
expect(container.querySelector('ol')?.getAttribute('start')).toBe('3')
|
||||
// The two mdast children make the item loose (wrap newlines around the
|
||||
// paragraph); the dropped definition contributes nothing else.
|
||||
expect(container.querySelector('li')?.textContent).toBe('\nbody\n')
|
||||
})
|
||||
|
||||
it('renders nothing for node types without a mapping', () => {
|
||||
const container = renderNodes([
|
||||
{ type: 'yaml', value: 'front: matter' },
|
||||
{ type: 'tableRow', children: [] },
|
||||
{ type: 'paragraph', children: [text('after')] },
|
||||
])
|
||||
expect(container.textContent).toBe('after')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderFootnoteSection edge shapes', () => {
|
||||
it('skips referenced footnotes without definitions and returns null when none remain', () => {
|
||||
const context = makeContext()
|
||||
context.footnoteOrder.push('GHOST')
|
||||
context.footnoteCounts.set('GHOST', 1)
|
||||
expect(renderFootnoteSection(context)).toBeNull()
|
||||
})
|
||||
|
||||
it('renders no back-reference markers for an uncounted footnote', () => {
|
||||
const context = makeContext()
|
||||
context.targets.footnotes.set('Q', {
|
||||
type: 'footnoteDefinition',
|
||||
identifier: 'q',
|
||||
children: [{ type: 'paragraph', children: [text('quiet')] }],
|
||||
})
|
||||
context.footnoteOrder.push('Q')
|
||||
const { container } = render(<div>{renderFootnoteSection(context)}</div>)
|
||||
expect(container.querySelector('li')?.textContent).toBe('\nquiet \n')
|
||||
})
|
||||
|
||||
it('appends back-references after a non-paragraph body', () => {
|
||||
const context = makeContext()
|
||||
context.targets.footnotes.set('N', {
|
||||
type: 'footnoteDefinition',
|
||||
identifier: 'n',
|
||||
children: [{ type: 'code', value: 'code body', lang: null }],
|
||||
})
|
||||
context.footnoteOrder.push('N')
|
||||
context.footnoteCounts.set('N', 1)
|
||||
const { container } = render(<div>{renderFootnoteSection(context)}</div>)
|
||||
const item = container.querySelector('li')
|
||||
expect(item?.querySelector('.md-code-block')).not.toBeNull()
|
||||
expect(item?.textContent).toContain('↩')
|
||||
})
|
||||
})
|
||||
|
||||
describe('MarkdownText under StrictMode', () => {
|
||||
it('streams identically when React double-invokes render work', () => {
|
||||
const doc = 'one\n\ntwo\n\nthree\n\nfour\n\nfive'
|
||||
const strict = render(<StrictMode><MarkdownText text={doc.slice(0, 8)} streaming /></StrictMode>)
|
||||
strict.rerender(<StrictMode><MarkdownText text={doc} streaming /></StrictMode>)
|
||||
const plain = render(<MarkdownText text={doc} streaming />)
|
||||
expect(strict.container.innerHTML).toBe(plain.container.innerHTML)
|
||||
strict.unmount()
|
||||
plain.unmount()
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,8 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { Extension } from 'micromark-util-types'
|
||||
import { JsonBlock, MarkdownText, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { remarkMathCompatibility } from '../src/markdown/remarkMathCompatibility.ts'
|
||||
import { mathCompatibility } from '../src/markdown/mathCompatibility.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -321,11 +320,11 @@ describe('MarkdownText', () => {
|
||||
expect(container.querySelector('pre code')?.textContent).toContain('$$x \\tag{1}$$')
|
||||
})
|
||||
|
||||
it('registers the compatibility extension on a bare remark processor', () => {
|
||||
const data: { micromarkExtensions?: Extension[] } = {}
|
||||
remarkMathCompatibility.call({ data: () => data })
|
||||
it('exposes the compatibility syntax as a micromark extension', () => {
|
||||
const extension = mathCompatibility()
|
||||
|
||||
expect(data.micromarkExtensions).toHaveLength(1)
|
||||
expect(Object.keys(extension)).toEqual(['flow', 'text'])
|
||||
expect(mathCompatibility()).toBe(extension)
|
||||
})
|
||||
|
||||
it('defers TeX rendering while streaming so incomplete formulas never flash KaTeX errors', () => {
|
||||
|
||||
Reference in New Issue
Block a user