svelte-streamdown 2.5.2 → 2.6.1
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 +173 -2
- package/dist/Elements/Element.svelte +9 -0
- package/dist/Streamdown.svelte +4 -0
- package/dist/context.svelte.d.ts +10 -2
- 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/utils/parse-incomplete-markdown.js +121 -0
- package/dist/utils/url.js +0 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -63,6 +63,7 @@ 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.
|
|
@@ -489,7 +490,8 @@ This heading will use a custom component!`;
|
|
|
489
490
|
| `animation.tokenize` | `'word' \| 'char'` | `'word'` | Tokenization method for text animations |
|
|
490
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 |
|
|
491
492
|
| `extensions` | `Array<Extension>` | `[]` | Custom marked tokenizers to render special markdown blocks or inline tokens |
|
|
492
|
-
| `
|
|
493
|
+
| `mdxComponents` | `Record<string, Component>` | `{}` | Map of MDX component names to Svelte components (e.g., `{ Card, Button }`) |
|
|
494
|
+
| `children` | `Snippet<[{token:GenericToken, streamdown: StreamdownContext, children: Snippet` | `undefined` | Snippet used to render elements not supported by Streamdown, custom extensions, and MDX components |
|
|
493
495
|
|
|
494
496
|
#### All Available Customizable Elements:
|
|
495
497
|
|
|
@@ -505,7 +507,9 @@ This heading will use a custom component!`;
|
|
|
505
507
|
|
|
506
508
|
**Special Content**: `blockquote`, `hr`, `alert`, `mermaid`, `math`, `footnoteRef`, `inlineCitation`
|
|
507
509
|
|
|
508
|
-
**
|
|
510
|
+
**MDX Components**: Handled via a single `mdx` snippet that receives `token`, `props`, and `children`. Use `token.tagName` to differentiate between components.
|
|
511
|
+
|
|
512
|
+
**Note**: The above elements are **supported by Streamdown** and should be customized using individual props or the theme system. MDX components require the `mdx` snippet.
|
|
509
513
|
|
|
510
514
|
## 🎨 Theming System
|
|
511
515
|
|
|
@@ -612,6 +616,173 @@ Each component supports multiple themeable parts:
|
|
|
612
616
|
|
|
613
617
|
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.
|
|
614
618
|
|
|
619
|
+
## 🧩 MDX Component Support
|
|
620
|
+
|
|
621
|
+
Streamdown supports MDX-style JSX components, allowing you to embed custom Svelte components directly in your markdown content.
|
|
622
|
+
|
|
623
|
+
### Basic Usage
|
|
624
|
+
|
|
625
|
+
```svelte
|
|
626
|
+
<script>
|
|
627
|
+
import { Streamdown } from 'svelte-streamdown';
|
|
628
|
+
|
|
629
|
+
let content = `
|
|
630
|
+
# Using MDX Components
|
|
631
|
+
|
|
632
|
+
<Card title="Hello" count={42}>
|
|
633
|
+
This is **markdown content** inside a component!
|
|
634
|
+
</Card>
|
|
635
|
+
|
|
636
|
+
<Button label="Click me" active={true} />
|
|
637
|
+
`;
|
|
638
|
+
</script>
|
|
639
|
+
|
|
640
|
+
<Streamdown {content}>
|
|
641
|
+
{#snippet mdx({ token, props, children })}
|
|
642
|
+
{#if token.tagName === 'Card'}
|
|
643
|
+
<div class="rounded-lg border border-gray-200 p-4 shadow-sm">
|
|
644
|
+
<h3 class="text-xl font-bold">{props.title}</h3>
|
|
645
|
+
<p class="text-gray-600">Count: {props.count}</p>
|
|
646
|
+
<div class="mt-2">
|
|
647
|
+
{@render children()}
|
|
648
|
+
</div>
|
|
649
|
+
</div>
|
|
650
|
+
{:else if token.tagName === 'Button'}
|
|
651
|
+
<button class="rounded px-4 py-2 {props.active ? 'bg-blue-500 text-white' : 'bg-gray-200'}">
|
|
652
|
+
{props.label}
|
|
653
|
+
</button>
|
|
654
|
+
{:else}
|
|
655
|
+
{@render children()}
|
|
656
|
+
{/if}
|
|
657
|
+
{/snippet}
|
|
658
|
+
</Streamdown>
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
### Alternative: Using Svelte Components Directly
|
|
662
|
+
|
|
663
|
+
Instead of using the `mdx` snippet with conditional logic, you can pass Svelte components directly using the `mdxComponents` prop:
|
|
664
|
+
|
|
665
|
+
```svelte
|
|
666
|
+
<script>
|
|
667
|
+
import { Streamdown } from 'svelte-streamdown';
|
|
668
|
+
import Card from './Card.svelte';
|
|
669
|
+
import Button from './Button.svelte';
|
|
670
|
+
|
|
671
|
+
let content = `
|
|
672
|
+
# Using MDX Components
|
|
673
|
+
|
|
674
|
+
<Card title="Hello" count={42}>
|
|
675
|
+
This is **markdown content** inside a component!
|
|
676
|
+
</Card>
|
|
677
|
+
|
|
678
|
+
<Button label="Click me" active={true} />
|
|
679
|
+
`;
|
|
680
|
+
</script>
|
|
681
|
+
|
|
682
|
+
<Streamdown {content} mdxComponents={{ Card, Button }} />
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
**Your Svelte components** (`Card.svelte`, `Button.svelte`) should accept props and a `children` snippet:
|
|
686
|
+
|
|
687
|
+
```svelte
|
|
688
|
+
<!-- Card.svelte -->
|
|
689
|
+
<script>
|
|
690
|
+
let { title, count, children } = $props();
|
|
691
|
+
</script>
|
|
692
|
+
|
|
693
|
+
<div class="rounded-lg border border-gray-200 p-4 shadow-sm">
|
|
694
|
+
<h3 class="text-xl font-bold">{title}</h3>
|
|
695
|
+
<p class="text-gray-600">Count: {count}</p>
|
|
696
|
+
<div class="mt-2">
|
|
697
|
+
{@render children()}
|
|
698
|
+
</div>
|
|
699
|
+
</div>
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
```svelte
|
|
703
|
+
<!-- Button.svelte -->
|
|
704
|
+
<script>
|
|
705
|
+
let { label, active } = $props();
|
|
706
|
+
</script>
|
|
707
|
+
|
|
708
|
+
<button class="rounded px-4 py-2 {active ? 'bg-blue-500 text-white' : 'bg-gray-200'}">
|
|
709
|
+
{label}
|
|
710
|
+
</button>
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
This approach is cleaner when you have standalone component files, while the `mdx` snippet approach is better for inline component definitions or when you need shared logic across components.
|
|
714
|
+
|
|
715
|
+
### Supported Syntax
|
|
716
|
+
|
|
717
|
+
**Self-closing components:**
|
|
718
|
+
```markdown
|
|
719
|
+
<Component attr="value" count={42} enabled={true} />
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
**Components with markdown children:**
|
|
723
|
+
```markdown
|
|
724
|
+
<Component title="Hello">
|
|
725
|
+
# This is a heading
|
|
726
|
+
This **markdown** content will be parsed!
|
|
727
|
+
</Component>
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
### Attribute Types
|
|
731
|
+
|
|
732
|
+
MDX components support three attribute value types:
|
|
733
|
+
|
|
734
|
+
- **Strings**: `attr="hello"` → `"hello"`
|
|
735
|
+
- **Numbers**: `count={42}` or `value={3.14}` → `42`, `3.14`
|
|
736
|
+
- **Booleans**: `active={true}` or `disabled={false}` → `true`, `false`
|
|
737
|
+
- **Expressions**: `value={variableName}` → `"variableName"` (stored as string)
|
|
738
|
+
|
|
739
|
+
### Component Naming
|
|
740
|
+
|
|
741
|
+
- Component names **must start with a capital letter** (PascalCase)
|
|
742
|
+
- Valid: `<Card />`, `<MyComponent />`, `<Component123 />`
|
|
743
|
+
- Invalid: `<card />`, `<myComponent />` (these are treated as HTML)
|
|
744
|
+
|
|
745
|
+
### Streaming Safety
|
|
746
|
+
|
|
747
|
+
MDX components are streaming-safe. Incomplete components are automatically handled during AI streaming:
|
|
748
|
+
|
|
749
|
+
- Incomplete tags like `<Component attr` not rendered to prevent runtime errors
|
|
750
|
+
- Unclosed components like `<Card>content` are auto-closed with `</Card>`
|
|
751
|
+
- Malformed attributes are escaped to prevent rendering errors
|
|
752
|
+
|
|
753
|
+
This ensures your UI remains stable even when receiving partial markdown from streaming AI responses.
|
|
754
|
+
|
|
755
|
+
### Component Props
|
|
756
|
+
|
|
757
|
+
The `mdx` snippet receives three parameters:
|
|
758
|
+
- `token`: The full MdxToken with `tagName`, `attributes`, `selfClosing`, etc.
|
|
759
|
+
- `props`: Object containing all parsed attributes (e.g., `props.title`, `props.count`)
|
|
760
|
+
- `children`: Snippet containing parsed markdown content
|
|
761
|
+
|
|
762
|
+
Use `token.tagName` to determine which component is being rendered:
|
|
763
|
+
|
|
764
|
+
```svelte
|
|
765
|
+
<!-- Markdown: <Card title="Hello" count={5}>Content</Card> -->
|
|
766
|
+
<Streamdown {content}>
|
|
767
|
+
{#snippet mdx({ token, props, children })}
|
|
768
|
+
{#if token.tagName === 'Card'}
|
|
769
|
+
<div>
|
|
770
|
+
<h3>{props.title}</h3>
|
|
771
|
+
<span>Count: {props.count}</span>
|
|
772
|
+
{@render children()}
|
|
773
|
+
</div>
|
|
774
|
+
{:else if token.tagName === 'Alert'}
|
|
775
|
+
<div class="alert alert-{props.type}">
|
|
776
|
+
{@render children()}
|
|
777
|
+
</div>
|
|
778
|
+
{:else}
|
|
779
|
+
<!-- Fallback for unknown components -->
|
|
780
|
+
{@render children()}
|
|
781
|
+
{/if}
|
|
782
|
+
{/snippet}
|
|
783
|
+
</Streamdown>
|
|
784
|
+
```
|
|
785
|
+
|
|
615
786
|
## 💉 Extensibility
|
|
616
787
|
|
|
617
788
|
Streamdown is extensible through the use of custom extensions.
|
|
@@ -298,6 +298,15 @@
|
|
|
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
|
+
{@const Component = streamdown.mdxComponents?.[token.tagName]}
|
|
303
|
+
{#if Component}
|
|
304
|
+
<Component {token} {children} props={token.attributes} />
|
|
305
|
+
{:else}
|
|
306
|
+
<Slot props={{ token, children, props: token.attributes }} render={streamdown.snippets.mdx}>
|
|
307
|
+
{@render children()}
|
|
308
|
+
</Slot>
|
|
309
|
+
{/if}
|
|
301
310
|
{:else}
|
|
302
311
|
<!-- For tokens we don't handle specifically, it may certainely be a custom extension to to the children props to handle -->
|
|
303
312
|
{@render streamdown.children?.({ token, children, streamdown })}
|
package/dist/Streamdown.svelte
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
extensions,
|
|
30
30
|
sources,
|
|
31
31
|
inlineCitationsMode = 'carousel',
|
|
32
|
+
mdxComponents,
|
|
32
33
|
...snippets
|
|
33
34
|
}: StreamdownProps<Source> = $props();
|
|
34
35
|
|
|
@@ -118,6 +119,9 @@
|
|
|
118
119
|
},
|
|
119
120
|
get icons() {
|
|
120
121
|
return icons;
|
|
122
|
+
},
|
|
123
|
+
get mdxComponents() {
|
|
124
|
+
return mdxComponents;
|
|
121
125
|
}
|
|
122
126
|
});
|
|
123
127
|
|
package/dist/context.svelte.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Snippet } from 'svelte';
|
|
1
|
+
import type { Component, Snippet } from 'svelte';
|
|
2
2
|
import type { DeepPartialTheme, Theme } from './theme.js';
|
|
3
3
|
import type { MermaidConfig } from 'mermaid';
|
|
4
4
|
import type { KatexOptions } from 'katex';
|
|
@@ -30,7 +30,7 @@ export declare class StreamdownContext<Source extends Record<string, any> = Reco
|
|
|
30
30
|
});
|
|
31
31
|
}
|
|
32
32
|
export declare const useStreamdown: () => StreamdownContext<Record<string, any>>;
|
|
33
|
-
import type { AlertToken, MathToken, SubSupToken, TableToken, THead, TBody, TFoot, THeadRow, TRow, TD, TH, Extension, GenericToken, CitationToken } from './marked/index.js';
|
|
33
|
+
import type { AlertToken, MathToken, SubSupToken, TableToken, THead, TBody, TFoot, THeadRow, TRow, TD, TH, Extension, GenericToken, CitationToken, MdxToken } from './marked/index.js';
|
|
34
34
|
import type { Tokens } from 'marked';
|
|
35
35
|
import type { ListItemToken, ListToken } from './marked/marked-list.js';
|
|
36
36
|
import type { Footnote, FootnoteRef, FootnoteToken } from './marked/marked-footnotes.js';
|
|
@@ -73,6 +73,7 @@ type TokenSnippet = {
|
|
|
73
73
|
inlineCitationPopover: CitationToken;
|
|
74
74
|
inlineCitationContent: CitationToken;
|
|
75
75
|
inlineCitationPreview: CitationToken;
|
|
76
|
+
mdx: MdxToken;
|
|
76
77
|
};
|
|
77
78
|
type PredefinedElements = keyof TokenSnippet;
|
|
78
79
|
export type Snippets<Source extends Record<string, any> = Record<string, any>> = {
|
|
@@ -83,6 +84,8 @@ export type Snippets<Source extends Record<string, any> = Record<string, any>> =
|
|
|
83
84
|
} & (K extends 'inlineCitationContent' ? {
|
|
84
85
|
source: Source;
|
|
85
86
|
key: string;
|
|
87
|
+
} : K extends 'mdx' ? {
|
|
88
|
+
props: Record<string, number | string | boolean | null | undefined>;
|
|
86
89
|
} : {})
|
|
87
90
|
]>;
|
|
88
91
|
};
|
|
@@ -151,5 +154,10 @@ export type StreamdownProps<Source extends Record<string, any> = Record<string,
|
|
|
151
154
|
token: GenericToken;
|
|
152
155
|
children: Snippet;
|
|
153
156
|
}]>;
|
|
157
|
+
mdxComponents?: Record<string, Component<{
|
|
158
|
+
token: MdxToken;
|
|
159
|
+
children: Snippet;
|
|
160
|
+
props: any;
|
|
161
|
+
}, any, any>>;
|
|
154
162
|
} & Partial<Snippets<Source>>;
|
|
155
163
|
export {};
|
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
|
+
};
|
|
@@ -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
|
+
// Remove incomplete MDX syntax (don't render it)
|
|
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
|
+
// Simply remove the incomplete MDX tag
|
|
775
|
+
result = before;
|
|
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.js
CHANGED
|
@@ -29,7 +29,6 @@ export const transformUrl = (url, allowedPrefixes, defaultOrigin) => {
|
|
|
29
29
|
if (!url)
|
|
30
30
|
return null;
|
|
31
31
|
const parsedUrl = parseUrl(url, defaultOrigin);
|
|
32
|
-
console.log('parsedUrl', { parsedUrl, url });
|
|
33
32
|
if (!parsedUrl)
|
|
34
33
|
return null;
|
|
35
34
|
// If the input is path relative, we output a path relative URL as well,
|