svelte-streamdown 2.5.1 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -1
- package/dist/Elements/Element.svelte +12 -0
- package/dist/Elements/Image.svelte +10 -4
- package/dist/Elements/Link.svelte +7 -5
- package/dist/marked/index.d.ts +3 -2
- package/dist/marked/index.js +3 -2
- package/dist/marked/marked-mdx.d.ts +12 -0
- package/dist/marked/marked-mdx.js +124 -0
- package/dist/theme.js +2 -2
- package/dist/utils/parse-incomplete-markdown.js +121 -0
- package/dist/utils/url.d.ts +1 -0
- package/dist/utils/url.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -63,12 +63,17 @@ Full support for
|
|
|
63
63
|
- Complex tables
|
|
64
64
|
- Footnotes [^1]
|
|
65
65
|
- Inline citations [ref] [ref2]
|
|
66
|
+
- MDX components (embed custom Svelte components)
|
|
66
67
|
|
|
67
68
|
[^1]:
|
|
68
69
|
Reference render in a popover by default.
|
|
69
70
|
with _rich_ **content** support
|
|
70
71
|
and multiline
|
|
71
72
|
|
|
73
|
+
> [!NOTE]
|
|
74
|
+
> 🧠 **AI Prompting Tip:** For best results, use our [comprehensive prompt](/prompting) covering all supported markdown features.
|
|
75
|
+
|
|
76
|
+
|
|
72
77
|
### 💻 Interactive Code Blocks
|
|
73
78
|
|
|
74
79
|
- Syntax highlighting powered by Shiki
|
|
@@ -485,7 +490,7 @@ This heading will use a custom component!`;
|
|
|
485
490
|
| `animation.tokenize` | `'word' \| 'char'` | `'word'` | Tokenization method for text animations |
|
|
486
491
|
| `animation.animateOnMount` | `boolean` | `false` | Run the token animation on mount or not, useful if you render the Streamdown component in the same time as the first token is receive from the LLM |
|
|
487
492
|
| `extensions` | `Array<Extension>` | `[]` | Custom marked tokenizers to render special markdown blocks or inline tokens |
|
|
488
|
-
| `children` | `Snippet<[{token:GenericToken, streamdown: StreamdownContext, children: Snippet` | `undefined` | Snippet used to render
|
|
493
|
+
| `children` | `Snippet<[{token:GenericToken, streamdown: StreamdownContext, children: Snippet` | `undefined` | Snippet used to render elements not supported by Streamdown, custom extensions, and MDX components |
|
|
489
494
|
|
|
490
495
|
#### All Available Customizable Elements:
|
|
491
496
|
|
|
@@ -501,6 +506,8 @@ This heading will use a custom component!`;
|
|
|
501
506
|
|
|
502
507
|
**Special Content**: `blockquote`, `hr`, `alert`, `mermaid`, `math`, `footnoteRef`, `inlineCitation`
|
|
503
508
|
|
|
509
|
+
**MDX Components**: Any PascalCase component (e.g., `Card`, `Button`, `MyComponent`) - pass as snippets with the component name
|
|
510
|
+
|
|
504
511
|
**Note**: The above elements are **supported by Streamdown** and should be customized using individual props or the theme system.
|
|
505
512
|
|
|
506
513
|
## 🎨 Theming System
|
|
@@ -608,6 +615,107 @@ Each component supports multiple themeable parts:
|
|
|
608
615
|
|
|
609
616
|
Themes are intelligently merged using Tailwind's class merging utility, so you only need to override the specific parts you want to customize while keeping the default styling for everything else.
|
|
610
617
|
|
|
618
|
+
## 🧩 MDX Component Support
|
|
619
|
+
|
|
620
|
+
Streamdown supports MDX-style JSX components, allowing you to embed custom Svelte components directly in your markdown content.
|
|
621
|
+
|
|
622
|
+
### Basic Usage
|
|
623
|
+
|
|
624
|
+
```svelte
|
|
625
|
+
<script>
|
|
626
|
+
import { Streamdown } from 'svelte-streamdown';
|
|
627
|
+
|
|
628
|
+
let content = `
|
|
629
|
+
# Using MDX Components
|
|
630
|
+
|
|
631
|
+
<Card title="Hello" count={42}>
|
|
632
|
+
This is **markdown content** inside a component!
|
|
633
|
+
</Card>
|
|
634
|
+
|
|
635
|
+
<Button label="Click me" active={true} />
|
|
636
|
+
`;
|
|
637
|
+
</script>
|
|
638
|
+
|
|
639
|
+
<Streamdown {content}>
|
|
640
|
+
{#snippet Card({ title, count, children })}
|
|
641
|
+
<div class="rounded-lg border border-gray-200 p-4 shadow-sm">
|
|
642
|
+
<h3 class="text-xl font-bold">{title}</h3>
|
|
643
|
+
<p class="text-gray-600">Count: {count}</p>
|
|
644
|
+
<div class="mt-2">
|
|
645
|
+
{@render children()}
|
|
646
|
+
</div>
|
|
647
|
+
</div>
|
|
648
|
+
{/snippet}
|
|
649
|
+
|
|
650
|
+
{#snippet Button({ label, active })}
|
|
651
|
+
<button class="rounded px-4 py-2 {active ? 'bg-blue-500 text-white' : 'bg-gray-200'}">
|
|
652
|
+
{label}
|
|
653
|
+
</button>
|
|
654
|
+
{/snippet}
|
|
655
|
+
</Streamdown>
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
### Supported Syntax
|
|
659
|
+
|
|
660
|
+
**Self-closing components:**
|
|
661
|
+
```markdown
|
|
662
|
+
<Component attr="value" count={42} enabled={true} />
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
**Components with markdown children:**
|
|
666
|
+
```markdown
|
|
667
|
+
<Component title="Hello">
|
|
668
|
+
# This is a heading
|
|
669
|
+
This **markdown** content will be parsed!
|
|
670
|
+
</Component>
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
### Attribute Types
|
|
674
|
+
|
|
675
|
+
MDX components support three attribute value types:
|
|
676
|
+
|
|
677
|
+
- **Strings**: `attr="hello"` → `"hello"`
|
|
678
|
+
- **Numbers**: `count={42}` or `value={3.14}` → `42`, `3.14`
|
|
679
|
+
- **Booleans**: `active={true}` or `disabled={false}` → `true`, `false`
|
|
680
|
+
- **Expressions**: `value={variableName}` → `"variableName"` (stored as string)
|
|
681
|
+
|
|
682
|
+
### Component Naming
|
|
683
|
+
|
|
684
|
+
- Component names **must start with a capital letter** (PascalCase)
|
|
685
|
+
- Valid: `<Card />`, `<MyComponent />`, `<Component123 />`
|
|
686
|
+
- Invalid: `<card />`, `<myComponent />` (these are treated as HTML)
|
|
687
|
+
|
|
688
|
+
### Streaming Safety
|
|
689
|
+
|
|
690
|
+
MDX components are streaming-safe. Incomplete components are automatically handled during AI streaming:
|
|
691
|
+
|
|
692
|
+
- Incomplete tags like `<Component attr` are escaped with backticks
|
|
693
|
+
- Unclosed components like `<Card>content` are auto-closed with `</Card>`
|
|
694
|
+
- Malformed attributes are escaped to prevent rendering errors
|
|
695
|
+
|
|
696
|
+
This ensures your UI remains stable even when receiving partial markdown from streaming AI responses.
|
|
697
|
+
|
|
698
|
+
### Component Props
|
|
699
|
+
|
|
700
|
+
MDX component snippets receive:
|
|
701
|
+
- `token`: The full MdxToken with `tagName`, `attributes`, etc.
|
|
702
|
+
- `children`: Snippet containing parsed markdown content
|
|
703
|
+
- **All attributes spread directly**: Access attributes by name (e.g., `title`, `count`, `active`)
|
|
704
|
+
|
|
705
|
+
Example:
|
|
706
|
+
```svelte
|
|
707
|
+
<!-- Markdown: <Card title="Hello" count={5}>Content</Card> -->
|
|
708
|
+
<Streamdown {content}>
|
|
709
|
+
{#snippet Card({ title, count, children })}
|
|
710
|
+
<div>
|
|
711
|
+
<h3>{title}</h3>
|
|
712
|
+
<span>Count: {count}</span>
|
|
713
|
+
{@render children()}
|
|
714
|
+
</div>
|
|
715
|
+
{/snippet}
|
|
716
|
+
</Streamdown>
|
|
717
|
+
```
|
|
718
|
+
|
|
611
719
|
## 💉 Extensibility
|
|
612
720
|
|
|
613
721
|
Streamdown is extensible through the use of custom extensions.
|
|
@@ -298,6 +298,18 @@
|
|
|
298
298
|
typeof streamdown.renderHtml === 'function' ? streamdown.renderHtml(token) : token.raw}
|
|
299
299
|
{@html content}
|
|
300
300
|
{/if}
|
|
301
|
+
{:else if token.type === 'mdx'}
|
|
302
|
+
{#if token.tagName in streamdown.snippets}
|
|
303
|
+
<Slot
|
|
304
|
+
props={{ token, children, ...token.attributes }}
|
|
305
|
+
render={streamdown.snippets[token.tagName as keyof typeof streamdown.snippets]}
|
|
306
|
+
>
|
|
307
|
+
{@render children()}
|
|
308
|
+
</Slot>
|
|
309
|
+
{:else}
|
|
310
|
+
<!-- Fallback if no snippet provided for this component -->
|
|
311
|
+
{@render children()}
|
|
312
|
+
{/if}
|
|
301
313
|
{:else}
|
|
302
314
|
<!-- For tokens we don't handle specifically, it may certainely be a custom extension to to the children props to handle -->
|
|
303
315
|
{@render streamdown.children?.({ token, children, streamdown })}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { useStreamdown } from '../context.svelte.js';
|
|
3
|
-
import { transformUrl } from '../utils/url.js';
|
|
3
|
+
import { isPathRelativeUrl, transformUrl } from '../utils/url.js';
|
|
4
4
|
import Slot from './Slot.svelte';
|
|
5
5
|
import type { Tokens } from 'marked';
|
|
6
6
|
import type { Snippet } from 'svelte';
|
|
@@ -17,16 +17,18 @@
|
|
|
17
17
|
id: string;
|
|
18
18
|
} = $props();
|
|
19
19
|
|
|
20
|
+
const isRelativeUrl = $derived(isPathRelativeUrl(token.href));
|
|
21
|
+
|
|
20
22
|
const transformedUrl = $derived(
|
|
21
23
|
transformUrl(token.href, streamdown.allowedImagePrefixes ?? [], streamdown.defaultOrigin)
|
|
22
24
|
);
|
|
23
25
|
</script>
|
|
24
26
|
|
|
25
27
|
{#if token.href !== 'streamdown:incomplete-image'}
|
|
26
|
-
{#if transformedUrl}
|
|
28
|
+
{#if transformedUrl || isRelativeUrl}
|
|
27
29
|
<Slot
|
|
28
30
|
props={{
|
|
29
|
-
src: transformedUrl,
|
|
31
|
+
src: isRelativeUrl ? token.href : transformedUrl,
|
|
30
32
|
alt: token.text,
|
|
31
33
|
children,
|
|
32
34
|
token
|
|
@@ -38,7 +40,11 @@
|
|
|
38
40
|
style={streamdown.isMounted ? streamdown.animationBlockStyle : ''}
|
|
39
41
|
class={streamdown.theme.image.base}
|
|
40
42
|
>
|
|
41
|
-
<img
|
|
43
|
+
<img
|
|
44
|
+
class={streamdown.theme.image.image}
|
|
45
|
+
src={isRelativeUrl ? token.href : transformedUrl}
|
|
46
|
+
alt={token.text}
|
|
47
|
+
/>
|
|
42
48
|
</span>
|
|
43
49
|
</Slot>
|
|
44
50
|
{:else}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { useStreamdown } from '../context.svelte.js';
|
|
3
|
-
import { transformUrl } from '../utils/url.js';
|
|
3
|
+
import { isPathRelativeUrl, transformUrl } from '../utils/url.js';
|
|
4
4
|
import Slot from './Slot.svelte';
|
|
5
5
|
import type { Tokens } from 'marked';
|
|
6
6
|
import type { Snippet } from 'svelte';
|
|
@@ -17,12 +17,14 @@
|
|
|
17
17
|
id: string;
|
|
18
18
|
} = $props();
|
|
19
19
|
|
|
20
|
+
const isRelativeUrl = $derived(isPathRelativeUrl(token.href));
|
|
21
|
+
|
|
20
22
|
const transformedUrl = $derived(
|
|
21
23
|
transformUrl(token.href, streamdown.allowedLinkPrefixes ?? [], streamdown.defaultOrigin)
|
|
22
24
|
);
|
|
23
25
|
</script>
|
|
24
26
|
|
|
25
|
-
{#if transformedUrl || token.href === 'streamdown:incomplete-link'}
|
|
27
|
+
{#if transformedUrl || token.href === 'streamdown:incomplete-link' || isRelativeUrl}
|
|
26
28
|
<Slot
|
|
27
29
|
props={{
|
|
28
30
|
href: transformedUrl,
|
|
@@ -37,9 +39,9 @@
|
|
|
37
39
|
<a
|
|
38
40
|
data-streamdown-link={id}
|
|
39
41
|
class={streamdown.theme.link.base}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
42
|
+
{...isRelativeUrl
|
|
43
|
+
? { href: token.href }
|
|
44
|
+
: { href: transformedUrl, target: '_blank', rel: 'noopener noreferrer' }}
|
|
43
45
|
>
|
|
44
46
|
{@render children()}
|
|
45
47
|
</a>
|
package/dist/marked/index.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { type TableToken, type THead, type TBody, type TFoot, type THeadRow, typ
|
|
|
10
10
|
import { type DescriptionDetailToken, type DescriptionListToken, type DescriptionTermToken, type DescriptionToken } from './marked-dl.js';
|
|
11
11
|
import { type AlignToken } from './marked-align.js';
|
|
12
12
|
import { type CitationToken } from './marked-citations.js';
|
|
13
|
+
import { type MdxToken } from './marked-mdx.js';
|
|
13
14
|
export type GenericToken = {
|
|
14
15
|
type: string;
|
|
15
16
|
raw: string;
|
|
@@ -22,8 +23,8 @@ export type Extension = {
|
|
|
22
23
|
start?: TokenizerStartFunction;
|
|
23
24
|
applyInBlockParsing?: boolean;
|
|
24
25
|
};
|
|
25
|
-
export type StreamdownToken = Exclude<MarkedToken, Tokens.List | Tokens.ListItem | Tokens.Table> | ListToken | ListItemToken | MathToken | AlertToken | FootnoteToken | SubSupToken | BrToken | HrToken | TableToken | THead | TBody | TFoot | THeadRow | TRow | TH | TD | DescriptionListToken | DescriptionToken | DescriptionDetailToken | DescriptionTermToken | AlignToken | CitationToken;
|
|
26
|
+
export type StreamdownToken = Exclude<MarkedToken, Tokens.List | Tokens.ListItem | Tokens.Table> | ListToken | ListItemToken | MathToken | AlertToken | FootnoteToken | SubSupToken | BrToken | HrToken | TableToken | THead | TBody | TFoot | THeadRow | TRow | TH | TD | DescriptionListToken | DescriptionToken | DescriptionDetailToken | DescriptionTermToken | AlignToken | CitationToken | MdxToken;
|
|
26
27
|
export type { TableToken, THead, TBody, TFoot, THeadRow, TRow, TH, TD } from './marked-table.js';
|
|
27
28
|
export declare const lex: (markdown: string, extensions?: Extension[]) => StreamdownToken[];
|
|
28
29
|
export declare const parseBlocks: (markdown: string, extensions?: Extension[]) => string[];
|
|
29
|
-
export type { MathToken, AlertToken, FootnoteToken, SubSupToken, BrToken, HrToken, AlignToken, CitationToken };
|
|
30
|
+
export type { MathToken, AlertToken, FootnoteToken, SubSupToken, BrToken, HrToken, AlignToken, CitationToken, MdxToken };
|
package/dist/marked/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import { markedTable } from './marked-table.js';
|
|
|
10
10
|
import { markedDl } from './marked-dl.js';
|
|
11
11
|
import { markedAlign } from './marked-align.js';
|
|
12
12
|
import { markedCitations } from './marked-citations.js';
|
|
13
|
+
import { markedMdx } from './marked-mdx.js';
|
|
13
14
|
const parseExtensions = (...extensions) => {
|
|
14
15
|
const options = {
|
|
15
16
|
gfm: true,
|
|
@@ -43,12 +44,12 @@ const parseExtensions = (...extensions) => {
|
|
|
43
44
|
return options;
|
|
44
45
|
};
|
|
45
46
|
export const lex = (markdown, extensions = []) => {
|
|
46
|
-
return new Lexer(parseExtensions(markedHr, markedTable, ...markedFootnote(), markedAlert, ...markedMath, markedSub, markedSup, markedList, markedBr, markedDl, markedAlign, markedCitations, ...extensions))
|
|
47
|
+
return new Lexer(parseExtensions(markedHr, markedTable, ...markedFootnote(), markedAlert, ...markedMath, markedSub, markedSup, markedList, markedBr, markedDl, markedAlign, markedCitations, markedMdx, ...extensions))
|
|
47
48
|
.lex(markdown)
|
|
48
49
|
.filter((token) => token.type !== 'space' && token.type !== 'footnote');
|
|
49
50
|
};
|
|
50
51
|
export const parseBlocks = (markdown, extensions = []) => {
|
|
51
|
-
const blockLexer = new Lexer(parseExtensions(markedHr, ...markedFootnote(), markedDl, markedTable, markedAlign, ...extensions.filter(({ level, applyInBlockParsing }) => level === 'block' && applyInBlockParsing)));
|
|
52
|
+
const blockLexer = new Lexer(parseExtensions(markedHr, ...markedFootnote(), markedDl, markedTable, markedAlign, markedMdx, ...extensions.filter(({ level, applyInBlockParsing }) => level === 'block' && applyInBlockParsing)));
|
|
52
53
|
return blockLexer.blockTokens(markdown, []).reduce((acc, block) => {
|
|
53
54
|
if (block.type === 'space' || block.type === 'footnote') {
|
|
54
55
|
return acc;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Extension } from './index.js';
|
|
2
|
+
import type { Token } from 'marked';
|
|
3
|
+
export type MdxToken = {
|
|
4
|
+
type: 'mdx';
|
|
5
|
+
raw: string;
|
|
6
|
+
tagName: string;
|
|
7
|
+
attributes: Record<string, any>;
|
|
8
|
+
selfClosing: boolean;
|
|
9
|
+
tokens?: Token[];
|
|
10
|
+
text?: string;
|
|
11
|
+
};
|
|
12
|
+
export declare const markedMdx: Extension;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { Lexer } from 'marked';
|
|
2
|
+
const defaultLexer = new Lexer({ gfm: true });
|
|
3
|
+
const defaultTokenizer = defaultLexer.options.tokenizer;
|
|
4
|
+
/**
|
|
5
|
+
* Parse attributes from MDX component tag
|
|
6
|
+
* Supports: attribute="string", attribute={number}, attribute={boolean}, attribute={expression}
|
|
7
|
+
*/
|
|
8
|
+
function parseAttributes(attributeString) {
|
|
9
|
+
const attributes = {};
|
|
10
|
+
// Pattern: attr="value" or attr={value}
|
|
11
|
+
const attrPattern = /(\w+)=(?:"([^"]*)"|{([^}]*)})/g;
|
|
12
|
+
let match;
|
|
13
|
+
while ((match = attrPattern.exec(attributeString)) !== null) {
|
|
14
|
+
const [, name, stringValue, expressionValue] = match;
|
|
15
|
+
if (stringValue !== undefined) {
|
|
16
|
+
// String attribute: attr="value"
|
|
17
|
+
attributes[name] = stringValue;
|
|
18
|
+
}
|
|
19
|
+
else if (expressionValue !== undefined) {
|
|
20
|
+
// Expression attribute: attr={value}
|
|
21
|
+
const trimmed = expressionValue.trim();
|
|
22
|
+
// Try to parse as number
|
|
23
|
+
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
|
|
24
|
+
attributes[name] = parseFloat(trimmed);
|
|
25
|
+
}
|
|
26
|
+
// Parse boolean
|
|
27
|
+
else if (trimmed === 'true') {
|
|
28
|
+
attributes[name] = true;
|
|
29
|
+
}
|
|
30
|
+
else if (trimmed === 'false') {
|
|
31
|
+
attributes[name] = false;
|
|
32
|
+
}
|
|
33
|
+
// Otherwise keep as string (could be variable reference, etc.)
|
|
34
|
+
else {
|
|
35
|
+
attributes[name] = trimmed;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return attributes;
|
|
40
|
+
}
|
|
41
|
+
export const markedMdx = {
|
|
42
|
+
name: 'mdx',
|
|
43
|
+
level: 'block',
|
|
44
|
+
applyInBlockParsing: true,
|
|
45
|
+
tokenizer(src) {
|
|
46
|
+
// Match MDX component tags (must start with capital letter)
|
|
47
|
+
// Self-closing: <Component attr="value" />
|
|
48
|
+
// With children: <Component attr="value">content</Component>
|
|
49
|
+
// First try self-closing tag
|
|
50
|
+
const selfClosingMatch = src.match(/^<([A-Z][a-zA-Z0-9]*)((?:\s+\w+=(?:"[^"]*"|{[^}]*}))*)\s*\/>/);
|
|
51
|
+
if (selfClosingMatch) {
|
|
52
|
+
const [raw, tagName, attributeString] = selfClosingMatch;
|
|
53
|
+
const attributes = parseAttributes(attributeString);
|
|
54
|
+
return {
|
|
55
|
+
type: 'mdx',
|
|
56
|
+
raw,
|
|
57
|
+
tagName,
|
|
58
|
+
attributes,
|
|
59
|
+
selfClosing: true
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
// Try paired tag with children
|
|
63
|
+
const openTagMatch = src.match(/^<([A-Z][a-zA-Z0-9]*)((?:\s+\w+=(?:"[^"]*"|{[^}]*}))*)\s*>/);
|
|
64
|
+
if (openTagMatch) {
|
|
65
|
+
const [openTag, tagName, attributeString] = openTagMatch;
|
|
66
|
+
const attributes = parseAttributes(attributeString);
|
|
67
|
+
// Find matching closing tag with nesting support
|
|
68
|
+
const closingTag = `</${tagName}>`;
|
|
69
|
+
// Escape special regex characters in tagName to prevent ReDoS
|
|
70
|
+
const escapedTagName = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
71
|
+
const openTagPattern = new RegExp(`<${escapedTagName}(?:\\s|>)`, 'g');
|
|
72
|
+
const closeTagPattern = new RegExp(`</${escapedTagName}>`, 'g');
|
|
73
|
+
let depth = 1;
|
|
74
|
+
let searchPos = openTag.length;
|
|
75
|
+
let closingIndex = -1;
|
|
76
|
+
while (depth > 0 && searchPos < src.length) {
|
|
77
|
+
openTagPattern.lastIndex = searchPos;
|
|
78
|
+
closeTagPattern.lastIndex = searchPos;
|
|
79
|
+
const nextOpen = openTagPattern.exec(src);
|
|
80
|
+
const nextClose = closeTagPattern.exec(src);
|
|
81
|
+
if (!nextClose)
|
|
82
|
+
break;
|
|
83
|
+
if (nextOpen && nextOpen.index < nextClose.index) {
|
|
84
|
+
// Verify this is not a self-closing tag by finding the full tag and checking for />
|
|
85
|
+
const tagStart = nextOpen.index;
|
|
86
|
+
const tagEndPos = src.indexOf('>', tagStart);
|
|
87
|
+
if (tagEndPos !== -1) {
|
|
88
|
+
const fullTag = src.substring(tagStart, tagEndPos + 1);
|
|
89
|
+
const isSelfClosing = fullTag.trimEnd().endsWith('/>');
|
|
90
|
+
if (!isSelfClosing) {
|
|
91
|
+
depth++;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
searchPos = openTagPattern.lastIndex;
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
depth--;
|
|
98
|
+
if (depth === 0) {
|
|
99
|
+
closingIndex = nextClose.index;
|
|
100
|
+
}
|
|
101
|
+
searchPos = closeTagPattern.lastIndex;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (closingIndex !== -1) {
|
|
105
|
+
// Extract content between tags
|
|
106
|
+
const contentStart = openTag.length;
|
|
107
|
+
const content = src.substring(contentStart, closingIndex);
|
|
108
|
+
const raw = src.substring(0, closingIndex + closingTag.length);
|
|
109
|
+
// Parse children as markdown
|
|
110
|
+
const tokens = content.trim() ? this.lexer.blockTokens(content.trim(), []) : [];
|
|
111
|
+
return {
|
|
112
|
+
type: 'mdx',
|
|
113
|
+
raw,
|
|
114
|
+
tagName,
|
|
115
|
+
attributes,
|
|
116
|
+
selfClosing: false,
|
|
117
|
+
tokens,
|
|
118
|
+
text: content
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
};
|
package/dist/theme.js
CHANGED
|
@@ -104,7 +104,7 @@ export const theme = {
|
|
|
104
104
|
mermaid: {
|
|
105
105
|
base: 'group relative my-4 h-auto rounded-xl border border-gray-200 bg-white overflow-hidden items-center min-h-[500px]',
|
|
106
106
|
icon: 'size-5',
|
|
107
|
-
buttons: 'absolute right-1 top-1
|
|
107
|
+
buttons: 'absolute right-1 top-1 flex h-fit w-fit items-center gap-1'
|
|
108
108
|
},
|
|
109
109
|
math: {
|
|
110
110
|
block: '',
|
|
@@ -257,7 +257,7 @@ export const shadcnTheme = {
|
|
|
257
257
|
mermaid: {
|
|
258
258
|
base: 'group relative my-4 h-auto rounded-lg border border-border bg-card overflow-hidden items-center min-h-[500px]',
|
|
259
259
|
icon: 'size-5',
|
|
260
|
-
buttons: 'absolute right-1 top-1
|
|
260
|
+
buttons: 'absolute right-1 top-1 flex h-fit w-fit items-center gap-1'
|
|
261
261
|
},
|
|
262
262
|
math: {
|
|
263
263
|
block: 'text-foreground',
|
|
@@ -670,6 +670,127 @@ class IncompleteMarkdownParser {
|
|
|
670
670
|
}
|
|
671
671
|
return line;
|
|
672
672
|
}
|
|
673
|
+
},
|
|
674
|
+
{
|
|
675
|
+
name: 'mdx',
|
|
676
|
+
skipInBlockTypes: ['code', 'math', 'center', 'right'],
|
|
677
|
+
preprocess: ({ text }) => {
|
|
678
|
+
// Track MDX component states across the entire text
|
|
679
|
+
const lines = text.split('\n');
|
|
680
|
+
const openTags = [];
|
|
681
|
+
let mdxLineStates = [];
|
|
682
|
+
for (let i = 0; i < lines.length; i++) {
|
|
683
|
+
const line = lines[i];
|
|
684
|
+
let inMdx = false;
|
|
685
|
+
let incompletePositions = [];
|
|
686
|
+
// Find all MDX tags in the line
|
|
687
|
+
let searchPos = 0;
|
|
688
|
+
while (searchPos < line.length) {
|
|
689
|
+
// Look for opening bracket with capital letter (MDX component)
|
|
690
|
+
const tagStart = line.indexOf('<', searchPos);
|
|
691
|
+
if (tagStart === -1 || tagStart >= line.length - 1)
|
|
692
|
+
break;
|
|
693
|
+
const nextChar = line[tagStart + 1];
|
|
694
|
+
// Only match if starts with capital letter (MDX component)
|
|
695
|
+
if (!/[A-Z]/.test(nextChar)) {
|
|
696
|
+
searchPos = tagStart + 1;
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
// Try to match complete self-closing tag
|
|
700
|
+
const selfClosingMatch = line
|
|
701
|
+
.substring(tagStart)
|
|
702
|
+
.match(/^<([A-Z][a-zA-Z0-9]*)((?:\s+\w+=(?:"[^"]*"|{[^}]*}))*)\s*\/>/);
|
|
703
|
+
if (selfClosingMatch) {
|
|
704
|
+
searchPos = tagStart + selfClosingMatch[0].length;
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
// Try to match complete opening tag with immediate closing
|
|
708
|
+
const completeMatch = line
|
|
709
|
+
.substring(tagStart)
|
|
710
|
+
.match(/^<([A-Z][a-zA-Z0-9]*)((?:\s+\w+=(?:"[^"]*"|{[^}]*}))*)\s*>.*?<\/\1>/);
|
|
711
|
+
if (completeMatch) {
|
|
712
|
+
searchPos = tagStart + completeMatch[0].length;
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
// Try to match opening tag
|
|
716
|
+
const openTagMatch = line
|
|
717
|
+
.substring(tagStart)
|
|
718
|
+
.match(/^<([A-Z][a-zA-Z0-9]*)((?:\s+\w+=(?:"[^"]*"|{[^}]*}))*)\s*>/);
|
|
719
|
+
if (openTagMatch) {
|
|
720
|
+
const tagName = openTagMatch[1];
|
|
721
|
+
openTags.push({ tagName, lineIndex: i });
|
|
722
|
+
inMdx = true;
|
|
723
|
+
searchPos = tagStart + openTagMatch[0].length;
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
// Check for incomplete self-closing (e.g., <Component /)
|
|
727
|
+
const incompleteSelfClosing = line
|
|
728
|
+
.substring(tagStart)
|
|
729
|
+
.match(/^<([A-Z][a-zA-Z0-9]*)[^>]*\/$/);
|
|
730
|
+
if (incompleteSelfClosing) {
|
|
731
|
+
incompletePositions.push(tagStart);
|
|
732
|
+
break; // This is at the end of the line
|
|
733
|
+
}
|
|
734
|
+
// Check for incomplete tag (no closing >) - only at end of line
|
|
735
|
+
const incompleteTag = line
|
|
736
|
+
.substring(tagStart)
|
|
737
|
+
.match(/^<([A-Z][a-zA-Z0-9]*)(?:\s+[^>]*)?$/);
|
|
738
|
+
if (incompleteTag) {
|
|
739
|
+
incompletePositions.push(tagStart);
|
|
740
|
+
break; // This is at the end of the line
|
|
741
|
+
}
|
|
742
|
+
searchPos = tagStart + 1;
|
|
743
|
+
}
|
|
744
|
+
// Check for closing tags
|
|
745
|
+
const closeTagMatches = line.matchAll(/<\/([A-Z][a-zA-Z0-9]*)>/g);
|
|
746
|
+
for (const closeMatch of closeTagMatches) {
|
|
747
|
+
const tagName = closeMatch[1];
|
|
748
|
+
// Find and remove the matching open tag
|
|
749
|
+
const openIndex = openTags.findIndex((t) => t.tagName === tagName);
|
|
750
|
+
if (openIndex !== -1) {
|
|
751
|
+
openTags.splice(openIndex, 1);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
mdxLineStates[i] = { inMdx, incompletePositions };
|
|
755
|
+
}
|
|
756
|
+
return {
|
|
757
|
+
text,
|
|
758
|
+
state: {
|
|
759
|
+
mdxUnclosedTags: openTags,
|
|
760
|
+
mdxLineStates
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
},
|
|
764
|
+
handler: ({ line, state }) => {
|
|
765
|
+
// Escape incomplete MDX syntax by wrapping in backticks
|
|
766
|
+
const lineStates = state.mdxLineStates || [];
|
|
767
|
+
const currentState = lineStates[state.currentLine];
|
|
768
|
+
if (currentState?.incompletePositions && currentState.incompletePositions.length > 0) {
|
|
769
|
+
// Process incomplete positions from right to left to preserve indices
|
|
770
|
+
let result = line;
|
|
771
|
+
for (let i = currentState.incompletePositions.length - 1; i >= 0; i--) {
|
|
772
|
+
const pos = currentState.incompletePositions[i];
|
|
773
|
+
const before = result.substring(0, pos);
|
|
774
|
+
const incomplete = result.substring(pos);
|
|
775
|
+
result = before + '`' + incomplete + '`';
|
|
776
|
+
}
|
|
777
|
+
return result;
|
|
778
|
+
}
|
|
779
|
+
return line;
|
|
780
|
+
},
|
|
781
|
+
postprocess: ({ text, state }) => {
|
|
782
|
+
// Complete unclosed MDX components at the end
|
|
783
|
+
const unclosedTags = state.mdxUnclosedTags || [];
|
|
784
|
+
if (unclosedTags.length > 0) {
|
|
785
|
+
// Close tags in reverse order (innermost first)
|
|
786
|
+
let result = text;
|
|
787
|
+
for (let i = unclosedTags.length - 1; i >= 0; i--) {
|
|
788
|
+
result += `\n</${unclosedTags[i].tagName}>`;
|
|
789
|
+
}
|
|
790
|
+
return result;
|
|
791
|
+
}
|
|
792
|
+
return text;
|
|
793
|
+
}
|
|
673
794
|
}
|
|
674
795
|
];
|
|
675
796
|
}
|
package/dist/utils/url.d.ts
CHANGED
package/dist/utils/url.js
CHANGED