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 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 element that are not supported by Streamdown and tokenized by your custom extensions |
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 class={streamdown.theme.image.image} src={transformedUrl} alt={token.text} />
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
- href={transformedUrl}
41
- target="_blank"
42
- rel="noopener noreferrer"
42
+ {...isRelativeUrl
43
+ ? { href: token.href }
44
+ : { href: transformedUrl, target: '_blank', rel: 'noopener noreferrer' }}
43
45
  >
44
46
  {@render children()}
45
47
  </a>
@@ -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
+ };
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 z-10 flex h-fit w-fit items-center gap-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 z-10 flex h-fit w-fit items-center gap-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
  }
@@ -1,2 +1,3 @@
1
1
  export declare const parseUrl: (url: unknown, defaultOrigin?: string) => URL | null;
2
+ export declare const isPathRelativeUrl: (url: unknown) => boolean;
2
3
  export declare const transformUrl: (url: unknown, allowedPrefixes: string[], defaultOrigin?: string) => string | null;
package/dist/utils/url.js CHANGED
@@ -20,7 +20,7 @@ export const parseUrl = (url, defaultOrigin) => {
20
20
  return null;
21
21
  }
22
22
  };
23
- const isPathRelativeUrl = (url) => {
23
+ export const isPathRelativeUrl = (url) => {
24
24
  if (typeof url !== 'string')
25
25
  return false;
26
26
  return url.startsWith('/');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-streamdown",
3
- "version": "2.5.1",
3
+ "version": "2.6.0",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && npm run prepack",