@humanspeak/svelte-markdown 1.0.5 → 1.1.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
@@ -27,6 +27,7 @@ A powerful, customizable markdown renderer for Svelte with TypeScript support. B
27
27
  - 🧪 Comprehensive test coverage (vitest and playwright)
28
28
  - 🧩 First-class marked extensions support via `extensions` prop (e.g., KaTeX math, alerts)
29
29
  - ⚡ Intelligent token caching (50-200x faster re-renders)
30
+ - 📡 LLM streaming mode with incremental rendering (~1.6ms avg per update)
30
31
  - 🖼️ Smart image lazy loading with fade-in animation
31
32
 
32
33
  ## Installation
@@ -678,6 +679,46 @@ Images automatically lazy load using native `loading="lazy"` and IntersectionObs
678
679
  <SvelteMarkdown source={markdown} {renderers} />
679
680
  ```
680
681
 
682
+ ### LLM Streaming
683
+
684
+ For real-time rendering of AI responses from ChatGPT, Claude, Gemini, and other LLMs, enable the `streaming` prop. This uses a smart diff algorithm that re-parses the full source for correctness but only updates changed DOM nodes, keeping render times constant regardless of document size.
685
+
686
+ ```svelte
687
+ <script lang="ts">
688
+ import SvelteMarkdown from '@humanspeak/svelte-markdown'
689
+
690
+ let source = $state('')
691
+
692
+ async function streamResponse() {
693
+ const response = await fetch('/api/chat', { method: 'POST', body: '...' })
694
+ const reader = response.body.getReader()
695
+ const decoder = new TextDecoder()
696
+
697
+ while (true) {
698
+ const { done, value } = await reader.read()
699
+ if (done) break
700
+ source += decoder.decode(value, { stream: true })
701
+ }
702
+ }
703
+ </script>
704
+
705
+ <SvelteMarkdown {source} streaming={true} />
706
+ ```
707
+
708
+ **Performance** (measured at 100 characters/sec, character mode):
709
+
710
+ | Metric | Standard Mode | Streaming Mode |
711
+ | -------------- | :-----------: | :------------: |
712
+ | Average render | ~3.6ms | ~1.6ms |
713
+ | Peak render | ~21ms | ~10ms |
714
+ | Dropped frames | 0 | 0 |
715
+
716
+ When `streaming` is `false` (default), existing behavior is unchanged. The `streaming` prop skips cache lookups (always a miss during streaming) and uses in-place token array mutation so Svelte only re-renders components for tokens that actually changed.
717
+
718
+ **Note:** `streaming` is automatically disabled when async extensions (e.g., `markedMermaid`) are used. A console warning is logged in this case.
719
+
720
+ See the [full streaming documentation](https://markdown.svelte.page/docs/advanced/llm-streaming) and [interactive demo](https://markdown.svelte.page/examples/llm-streaming).
721
+
681
722
  ## Available Renderers
682
723
 
683
724
  - `text` - Text within other elements
@@ -764,6 +805,7 @@ The component emits a `parsed` event when tokens are calculated:
764
805
  | Prop | Type | Description |
765
806
  | ---------- | ----------------------- | ------------------------------------------------ |
766
807
  | source | `string \| Token[]` | Markdown content or pre-parsed tokens |
808
+ | streaming | `boolean` | Enable incremental rendering for LLM streaming |
767
809
  | renderers | `Partial<Renderers>` | Custom component overrides |
768
810
  | options | `SvelteMarkdownOptions` | Marked parser configuration |
769
811
  | isInline | `boolean` | Toggle inline parsing mode |
@@ -51,6 +51,7 @@
51
51
 
52
52
  import Parser from './Parser.svelte'
53
53
  import { type SvelteMarkdownProps } from './types.js'
54
+ import { IncrementalParser } from './utils/incremental-parser.js'
54
55
  import {
55
56
  defaultOptions,
56
57
  defaultRenderers,
@@ -67,6 +68,7 @@
67
68
 
68
69
  const {
69
70
  source = [],
71
+ streaming = false,
70
72
  renderers = {},
71
73
  options = {},
72
74
  isInline = false,
@@ -93,9 +95,59 @@
93
95
  // Detect if any extension requires async processing
94
96
  const hasAsyncExtension = $derived(extensions.some((ext) => ext.async === true))
95
97
 
96
- // Synchronous token derivation (default fast path)
98
+ // Streaming mode: full re-parse + smart in-place diff
99
+ let incrementalParser: IncrementalParser | undefined
100
+ let lastOptionsKey = ''
101
+ let streamTokens = $state<Token[]>([])
102
+
103
+ $effect(() => {
104
+ if (!streaming || hasAsyncExtension) {
105
+ if (incrementalParser) {
106
+ incrementalParser = undefined
107
+ lastOptionsKey = ''
108
+ }
109
+ if (streaming && hasAsyncExtension) {
110
+ console.warn(
111
+ '[svelte-markdown] streaming prop is ignored when async extensions are used. ' +
112
+ 'Remove async extensions or set streaming={false} to silence this warning.'
113
+ )
114
+ }
115
+ return
116
+ }
117
+
118
+ // Read combinedOptions unconditionally so Svelte tracks it as a dependency
119
+ const currentOptions = combinedOptions
120
+ const optionsKey = JSON.stringify(currentOptions)
121
+
122
+ if (Array.isArray(source)) {
123
+ streamTokens = source as Token[]
124
+ return
125
+ }
126
+
127
+ if (source === '') {
128
+ if (incrementalParser) incrementalParser.reset()
129
+ streamTokens.length = 0
130
+ return
131
+ }
132
+
133
+ // Recreate parser only when options actually change
134
+ if (!incrementalParser || lastOptionsKey !== optionsKey) {
135
+ incrementalParser = new IncrementalParser(currentOptions)
136
+ lastOptionsKey = optionsKey
137
+ }
138
+ const { tokens: newTokens, divergeAt } = incrementalParser.update(source as string)
139
+
140
+ // In-place update: only touch changed/appended indices
141
+ for (let i = divergeAt; i < newTokens.length; i++) {
142
+ streamTokens[i] = newTokens[i]
143
+ }
144
+ streamTokens.length = newTokens.length
145
+ })
146
+
147
+ // Synchronous token derivation (default fast path — non-streaming)
97
148
  const syncTokens = $derived.by(() => {
98
149
  if (hasAsyncExtension) return undefined
150
+ if (streaming) return undefined
99
151
 
100
152
  // Pre-parsed tokens - skip caching and parsing
101
153
  if (Array.isArray(source)) {
@@ -107,7 +159,7 @@
107
159
  return []
108
160
  }
109
161
 
110
- // Parse with caching (handles cache lookup, parsing, and storage)
162
+ // Standard mode - full parse with caching
111
163
  return parseAndCacheTokens(source as string, combinedOptions, isInline)
112
164
  }) satisfies Token[] | TokensList | undefined
113
165
 
@@ -149,8 +201,8 @@
149
201
  })
150
202
  })
151
203
 
152
- // Unified tokens: prefer sync path, fall back to async
153
- const tokens = $derived(hasAsyncExtension ? asyncTokens : syncTokens)
204
+ // Unified tokens: streaming > sync > async
205
+ const tokens = $derived(streaming ? streamTokens : hasAsyncExtension ? asyncTokens : syncTokens)
154
206
 
155
207
  $effect(() => {
156
208
  if (!tokens) return
package/dist/index.d.ts CHANGED
@@ -58,6 +58,7 @@ export { htmlRendererKeysInternal as htmlRendererKeys, rendererKeysInternal as r
58
58
  * - `tokenCache` — shared singleton `TokenCache` instance
59
59
  */
60
60
  export { MemoryCache } from './utils/cache.js';
61
+ export { IncrementalParser, type IncrementalUpdateResult } from './utils/incremental-parser.js';
61
62
  export { TokenCache, tokenCache } from './utils/token-cache.js';
62
63
  /** Re-exported `MarkedExtension` type for the `extensions` prop. */
63
64
  export type { MarkedExtension } from 'marked';
package/dist/index.js CHANGED
@@ -57,4 +57,5 @@ export { htmlRendererKeysInternal as htmlRendererKeys, rendererKeysInternal as r
57
57
  * - `tokenCache` — shared singleton `TokenCache` instance
58
58
  */
59
59
  export { MemoryCache } from './utils/cache.js';
60
+ export { IncrementalParser } from './utils/incremental-parser.js';
60
61
  export { TokenCache, tokenCache } from './utils/token-cache.js';
package/dist/types.d.ts CHANGED
@@ -190,6 +190,20 @@ export type SvelteMarkdownProps<T extends Renderers = Renderers> = {
190
190
  * @defaultValue `false`
191
191
  */
192
192
  isInline?: boolean;
193
+ /**
194
+ * Enables optimized rendering for LLM streaming scenarios.
195
+ *
196
+ * When `true`, the component performs a full re-parse on each source
197
+ * update but diffs the resulting tokens against the previous parse.
198
+ * Only changed or appended tokens trigger DOM updates, keeping render
199
+ * cost proportional to the change rather than the full document size.
200
+ *
201
+ * Use this when appending tokens to `source` in a streaming fashion
202
+ * (e.g., ChatGPT/Claude SSE responses).
203
+ *
204
+ * @defaultValue `false`
205
+ */
206
+ streaming?: boolean;
193
207
  /**
194
208
  * Callback invoked after the source has been parsed into tokens.
195
209
  *
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Incremental Markdown Parser for Streaming
3
+ *
4
+ * Optimizes streaming scenarios (LLM token-by-token updates) by performing
5
+ * a full re-parse but diffing the result against the previous token array.
6
+ * Only changed/appended tokens are returned as updates, allowing Svelte to
7
+ * skip re-rendering unchanged components.
8
+ *
9
+ * @module incremental-parser
10
+ */
11
+ import type { SvelteMarkdownOptions } from '../types.js';
12
+ import type { Token } from './markdown-parser.js';
13
+ /**
14
+ * Result of an incremental parse update.
15
+ */
16
+ export interface IncrementalUpdateResult {
17
+ /** The full new token array */
18
+ tokens: Token[];
19
+ /** Index of the first token that differs from the previous parse */
20
+ divergeAt: number;
21
+ }
22
+ /**
23
+ * Streaming-optimized parser that performs full re-parses but diffs results
24
+ * against the previous token array to minimize DOM updates.
25
+ *
26
+ * For append-only streaming (typical LLM use case), most tokens are identical
27
+ * between updates. By comparing `raw` strings, we identify which tokens changed
28
+ * so Svelte can skip re-rendering unchanged components.
29
+ *
30
+ * @example
31
+ * ```typescript
32
+ * const parser = new IncrementalParser({ gfm: true })
33
+ *
34
+ * // First update — all tokens are "new"
35
+ * const r1 = parser.update('# Hello')
36
+ * // r1.divergeAt === 0
37
+ *
38
+ * // Second update — heading unchanged, paragraph appended
39
+ * const r2 = parser.update('# Hello\n\nWorld')
40
+ * // r2.divergeAt === 1 (heading at index 0 unchanged)
41
+ * ```
42
+ */
43
+ export declare class IncrementalParser {
44
+ /** Previous parse result for diffing */
45
+ private prevTokens;
46
+ /** Parser options passed to the Marked lexer */
47
+ private options;
48
+ /**
49
+ * Creates a new incremental parser instance.
50
+ *
51
+ * @param options - Svelte markdown parser options forwarded to Marked's Lexer
52
+ */
53
+ constructor(options: SvelteMarkdownOptions);
54
+ /**
55
+ * Parses the full source and diffs against the previous result.
56
+ *
57
+ * @param source - The full accumulated markdown source string
58
+ * @returns The new tokens and the index where they diverge from the previous parse
59
+ */
60
+ update: (source: string) => IncrementalUpdateResult;
61
+ /**
62
+ * Resets the parser state. Call this when starting a new stream.
63
+ */
64
+ reset: () => void;
65
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Incremental Markdown Parser for Streaming
3
+ *
4
+ * Optimizes streaming scenarios (LLM token-by-token updates) by performing
5
+ * a full re-parse but diffing the result against the previous token array.
6
+ * Only changed/appended tokens are returned as updates, allowing Svelte to
7
+ * skip re-rendering unchanged components.
8
+ *
9
+ * @module incremental-parser
10
+ */
11
+ import { lexAndClean } from './parse-and-cache.js';
12
+ /**
13
+ * Streaming-optimized parser that performs full re-parses but diffs results
14
+ * against the previous token array to minimize DOM updates.
15
+ *
16
+ * For append-only streaming (typical LLM use case), most tokens are identical
17
+ * between updates. By comparing `raw` strings, we identify which tokens changed
18
+ * so Svelte can skip re-rendering unchanged components.
19
+ *
20
+ * @example
21
+ * ```typescript
22
+ * const parser = new IncrementalParser({ gfm: true })
23
+ *
24
+ * // First update — all tokens are "new"
25
+ * const r1 = parser.update('# Hello')
26
+ * // r1.divergeAt === 0
27
+ *
28
+ * // Second update — heading unchanged, paragraph appended
29
+ * const r2 = parser.update('# Hello\n\nWorld')
30
+ * // r2.divergeAt === 1 (heading at index 0 unchanged)
31
+ * ```
32
+ */
33
+ export class IncrementalParser {
34
+ /** Previous parse result for diffing */
35
+ prevTokens = [];
36
+ /** Parser options passed to the Marked lexer */
37
+ options;
38
+ /**
39
+ * Creates a new incremental parser instance.
40
+ *
41
+ * @param options - Svelte markdown parser options forwarded to Marked's Lexer
42
+ */
43
+ constructor(options) {
44
+ this.options = options;
45
+ }
46
+ /**
47
+ * Parses the full source and diffs against the previous result.
48
+ *
49
+ * @param source - The full accumulated markdown source string
50
+ * @returns The new tokens and the index where they diverge from the previous parse
51
+ */
52
+ update = (source) => {
53
+ const newTokens = lexAndClean(source, this.options, false);
54
+ // Apply walkTokens if configured
55
+ if (typeof this.options.walkTokens === 'function') {
56
+ newTokens.forEach(this.options.walkTokens);
57
+ }
58
+ // Find first divergence point by comparing raw strings
59
+ let divergeAt = 0;
60
+ const minLen = Math.min(this.prevTokens.length, newTokens.length);
61
+ while (divergeAt < minLen) {
62
+ if (this.prevTokens[divergeAt].raw !== newTokens[divergeAt].raw)
63
+ break;
64
+ divergeAt++;
65
+ }
66
+ this.prevTokens = newTokens;
67
+ return { tokens: newTokens, divergeAt };
68
+ };
69
+ /**
70
+ * Resets the parser state. Call this when starting a new stream.
71
+ */
72
+ reset = () => {
73
+ this.prevTokens = [];
74
+ };
75
+ }
@@ -8,6 +8,24 @@
8
8
  */
9
9
  import type { SvelteMarkdownOptions } from '../types.js';
10
10
  import type { Token, TokensList } from './markdown-parser.js';
11
+ /**
12
+ * Lexes markdown source and cleans the resulting tokens. Shared by sync and async paths.
13
+ *
14
+ * @param source - Raw markdown string to lex
15
+ * @param options - Parser options forwarded to the Marked lexer
16
+ * @param isInline - When true, uses inline tokenization (no block elements)
17
+ * @returns Cleaned token array with HTML tokens properly nested
18
+ *
19
+ * @example
20
+ * ```typescript
21
+ * import { lexAndClean } from './parse-and-cache.js'
22
+ *
23
+ * const tokens = lexAndClean('# Hello **world**', { gfm: true }, false)
24
+ * ```
25
+ *
26
+ * @internal
27
+ */
28
+ export declare const lexAndClean: (source: string, options: SvelteMarkdownOptions, isInline: boolean) => Token[];
11
29
  /**
12
30
  * Parses markdown source with caching (synchronous path).
13
31
  * Checks cache first, parses on miss, stores result, and returns tokens.
@@ -17,9 +17,16 @@ import { Lexer, Marked } from 'marked';
17
17
  * @param isInline - When true, uses inline tokenization (no block elements)
18
18
  * @returns Cleaned token array with HTML tokens properly nested
19
19
  *
20
+ * @example
21
+ * ```typescript
22
+ * import { lexAndClean } from './parse-and-cache.js'
23
+ *
24
+ * const tokens = lexAndClean('# Hello **world**', { gfm: true }, false)
25
+ * ```
26
+ *
20
27
  * @internal
21
28
  */
22
- const lexAndClean = (source, options, isInline) => {
29
+ export const lexAndClean = (source, options, isInline) => {
23
30
  const lexer = new Lexer(options);
24
31
  const parsedTokens = isInline ? lexer.inlineTokens(source) : lexer.lex(source);
25
32
  return shrinkHtmlTokens(parsedTokens);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanspeak/svelte-markdown",
3
- "version": "1.0.5",
3
+ "version": "1.1.0",
4
4
  "description": "Fast, customizable markdown renderer for Svelte with built-in caching, TypeScript support, and Svelte 5 runes",
5
5
  "keywords": [
6
6
  "svelte",
@@ -69,8 +69,8 @@
69
69
  "dependencies": {
70
70
  "@humanspeak/memory-cache": "^1.0.6",
71
71
  "github-slugger": "^2.0.0",
72
- "htmlparser2": "^10.1.0",
73
- "marked": "^17.0.4"
72
+ "htmlparser2": "^12.0.0",
73
+ "marked": "^17.0.5"
74
74
  },
75
75
  "devDependencies": {
76
76
  "@eslint/compat": "^2.0.3",
@@ -86,32 +86,32 @@
86
86
  "@testing-library/user-event": "^14.6.1",
87
87
  "@types/katex": "^0.16.8",
88
88
  "@types/node": "^25.5.0",
89
- "@typescript-eslint/eslint-plugin": "^8.57.1",
90
- "@typescript-eslint/parser": "^8.57.1",
91
- "@vitest/coverage-v8": "^4.1.0",
92
- "eslint": "^10.0.3",
89
+ "@typescript-eslint/eslint-plugin": "^8.57.2",
90
+ "@typescript-eslint/parser": "^8.57.2",
91
+ "@vitest/coverage-v8": "^4.1.1",
92
+ "eslint": "^10.1.0",
93
93
  "eslint-config-prettier": "^10.1.8",
94
94
  "eslint-plugin-import": "^2.32.0",
95
- "eslint-plugin-svelte": "^3.15.2",
95
+ "eslint-plugin-svelte": "^3.16.0",
96
96
  "eslint-plugin-unused-imports": "^4.4.1",
97
97
  "globals": "^17.4.0",
98
98
  "husky": "^9.1.7",
99
- "jsdom": "^29.0.0",
100
- "katex": "^0.16.38",
99
+ "jsdom": "^29.0.1",
100
+ "katex": "^0.16.41",
101
101
  "marked-katex-extension": "^5.1.7",
102
102
  "mermaid": "^11.13.0",
103
- "mprocs": "^0.8.3",
103
+ "mprocs": "^0.9.2",
104
104
  "prettier": "^3.8.1",
105
105
  "prettier-plugin-organize-imports": "^4.3.0",
106
106
  "prettier-plugin-svelte": "^3.5.1",
107
107
  "prettier-plugin-tailwindcss": "^0.7.2",
108
108
  "publint": "^0.3.18",
109
- "svelte": "^5.53.13",
109
+ "svelte": "^5.55.0",
110
110
  "svelte-check": "^4.4.5",
111
- "typescript": "^5.9.3",
112
- "typescript-eslint": "^8.57.1",
113
- "vite": "^8.0.0",
114
- "vitest": "^4.1.0"
111
+ "typescript": "^6.0.2",
112
+ "typescript-eslint": "^8.57.2",
113
+ "vite": "^8.0.2",
114
+ "vitest": "^4.1.1"
115
115
  },
116
116
  "peerDependencies": {
117
117
  "mermaid": ">=10.0.0",