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 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
- | `children` | `Snippet<[{token:GenericToken, streamdown: StreamdownContext, children: Snippet` | `undefined` | Snippet used to render element that are not supported by Streamdown and tokenized by your custom extensions |
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
- **Note**: The above elements are **supported by Streamdown** and should be customized using individual props or the theme system.
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 })}
@@ -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
 
@@ -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 {};
@@ -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 };
@@ -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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-streamdown",
3
- "version": "2.5.2",
3
+ "version": "2.6.1",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && npm run prepack",