@humanspeak/svelte-markdown 1.0.4 → 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 |
@@ -56,6 +56,9 @@
56
56
  RendererComponent
57
57
  } from './utils/markdown-parser.js'
58
58
 
59
+ // trunk-ignore(eslint/@typescript-eslint/no-explicit-any)
60
+ type AnySnippet = (..._args: any[]) => any
61
+
59
62
  interface Props<T extends Renderers = Renderers> {
60
63
  type?: string
61
64
  tokens?: Token[] | TokensList
@@ -63,8 +66,8 @@
63
66
  rows?: Tokens.TableCell[][]
64
67
  ordered?: boolean
65
68
  renderers: T
66
- snippetOverrides?: Record<string, any> // trunk-ignore(eslint/@typescript-eslint/no-explicit-any)
67
- htmlSnippetOverrides?: Record<string, any> // trunk-ignore(eslint/@typescript-eslint/no-explicit-any)
69
+ snippetOverrides?: Record<string, AnySnippet>
70
+ htmlSnippetOverrides?: Record<string, AnySnippet>
68
71
  }
69
72
 
70
73
  const {
@@ -121,14 +124,14 @@
121
124
  {#if cellSnippet}
122
125
  {@render cellSnippet({
123
126
  header: true,
124
- align: (rest.align as string[])[i],
127
+ align: (rest.align as string[] | undefined)?.[i] ?? null,
125
128
  ...cellRest,
126
129
  children: headerCellContent
127
130
  })}
128
131
  {:else}
129
132
  <renderers.tablecell
130
133
  header={true}
131
- align={(rest.align as string[])[i]}
134
+ align={(rest.align as string[] | undefined)?.[i] ?? null}
132
135
  {...cellRest}
133
136
  >
134
137
  {@render headerCellContent()}
@@ -172,7 +175,8 @@
172
175
  {#if cellSnippet}
173
176
  {@render cellSnippet({
174
177
  header: false,
175
- align: (rest.align as string[])[j],
178
+ align:
179
+ (rest.align as string[] | undefined)?.[j] ?? null,
176
180
  ...cellRest,
177
181
  children: bodyCellContent
178
182
  })}
@@ -180,7 +184,8 @@
180
184
  <renderers.tablecell
181
185
  {...cellRest}
182
186
  header={false}
183
- align={(rest.align as string[])[j]}
187
+ align={(rest.align as string[] | undefined)?.[j] ??
188
+ null}
184
189
  >
185
190
  {@render bodyCellContent()}
186
191
  </renderers.tablecell>
@@ -46,6 +46,7 @@
46
46
  */
47
47
  import Parser from './Parser.svelte';
48
48
  import type { Renderers, Token, TokensList, Tokens } from './utils/markdown-parser.js';
49
+ type AnySnippet = (..._args: any[]) => any;
49
50
  interface Props<T extends Renderers = Renderers> {
50
51
  type?: string;
51
52
  tokens?: Token[] | TokensList;
@@ -53,8 +54,8 @@ interface Props<T extends Renderers = Renderers> {
53
54
  rows?: Tokens.TableCell[][];
54
55
  ordered?: boolean;
55
56
  renderers: T;
56
- snippetOverrides?: Record<string, any>;
57
- htmlSnippetOverrides?: Record<string, any>;
57
+ snippetOverrides?: Record<string, AnySnippet>;
58
+ htmlSnippetOverrides?: Record<string, AnySnippet>;
58
59
  }
59
60
  type $$ComponentProps = Props & {
60
61
  [key: string]: unknown;
@@ -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,
@@ -62,8 +63,12 @@
62
63
  import { rendererKeysInternal } from './utils/rendererKeys.js'
63
64
  import { Marked } from 'marked'
64
65
 
66
+ // trunk-ignore(eslint/@typescript-eslint/no-explicit-any)
67
+ type AnySnippet = (..._args: any[]) => any
68
+
65
69
  const {
66
70
  source = [],
71
+ streaming = false,
67
72
  renderers = {},
68
73
  options = {},
69
74
  isInline = false,
@@ -90,9 +95,59 @@
90
95
  // Detect if any extension requires async processing
91
96
  const hasAsyncExtension = $derived(extensions.some((ext) => ext.async === true))
92
97
 
93
- // 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)
94
148
  const syncTokens = $derived.by(() => {
95
149
  if (hasAsyncExtension) return undefined
150
+ if (streaming) return undefined
96
151
 
97
152
  // Pre-parsed tokens - skip caching and parsing
98
153
  if (Array.isArray(source)) {
@@ -104,7 +159,7 @@
104
159
  return []
105
160
  }
106
161
 
107
- // Parse with caching (handles cache lookup, parsing, and storage)
162
+ // Standard mode - full parse with caching
108
163
  return parseAndCacheTokens(source as string, combinedOptions, isInline)
109
164
  }) satisfies Token[] | TokensList | undefined
110
165
 
@@ -146,8 +201,8 @@
146
201
  })
147
202
  })
148
203
 
149
- // Unified tokens: prefer sync path, fall back to async
150
- const tokens = $derived(hasAsyncExtension ? asyncTokens : syncTokens)
204
+ // Unified tokens: streaming > sync > async
205
+ const tokens = $derived(streaming ? streamTokens : hasAsyncExtension ? asyncTokens : syncTokens)
151
206
 
152
207
  $effect(() => {
153
208
  if (!tokens) return
@@ -174,7 +229,7 @@
174
229
  allRendererKeys
175
230
  .filter((key) => key in rest && rest[key] != null)
176
231
  .map((key) => [key, rest[key]])
177
- )
232
+ ) as Record<string, AnySnippet>
178
233
  )
179
234
 
180
235
  // Collect HTML snippet overrides (keys matching html_<tag>)
@@ -183,7 +238,7 @@
183
238
  Object.entries(rest)
184
239
  .filter(([key, val]) => key.startsWith('html_') && val != null)
185
240
  .map(([key, val]) => [key.slice(5), val])
186
- )
241
+ ) as Record<string, AnySnippet>
187
242
  )
188
243
 
189
244
  // Passthrough: everything that isn't a known snippet override
@@ -199,7 +254,7 @@
199
254
  {tokens}
200
255
  {...passThroughProps}
201
256
  options={combinedOptions}
202
- slug={(val: string): string => (slugger ? slugger.slug(val) : '')}
257
+ slug={(val: string): string => slugger.slug(val)}
203
258
  renderers={combinedRenderers}
204
259
  {snippetOverrides}
205
260
  {htmlSnippetOverrides}
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';
@@ -4,7 +4,7 @@ Renders a table cell as either `<th>` (header) or `<td>` (data), with optional
4
4
  text alignment applied as an inline `text-align` style.
5
5
 
6
6
  @prop {boolean} header - When `true`, renders a `<th>`; otherwise renders a `<td>`.
7
- @prop {'left'|'center'|'right'|'justify'|'char'|null|undefined} align - Column alignment.
7
+ @prop {'left'|'center'|'right'|null} align - Column alignment.
8
8
  @prop {Snippet} [children] - Cell content.
9
9
  -->
10
10
  <script lang="ts">
@@ -12,7 +12,7 @@ text alignment applied as an inline `text-align` style.
12
12
 
13
13
  interface Props {
14
14
  header: boolean
15
- align: 'left' | 'center' | 'right' | 'justify' | 'char' | null | undefined
15
+ align: 'left' | 'center' | 'right' | null
16
16
  children?: Snippet
17
17
  }
18
18
 
@@ -1,7 +1,7 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  interface Props {
3
3
  header: boolean;
4
- align: 'left' | 'center' | 'right' | 'justify' | 'char' | null | undefined;
4
+ align: 'left' | 'center' | 'right' | null;
5
5
  children?: Snippet;
6
6
  }
7
7
  /**
@@ -9,7 +9,7 @@ interface Props {
9
9
  * text alignment applied as an inline `text-align` style.
10
10
  *
11
11
  * @prop {boolean} header - When `true`, renders a `<th>`; otherwise renders a `<td>`.
12
- * @prop {'left'|'center'|'right'|'justify'|'char'|null|undefined} align - Column alignment.
12
+ * @prop {'left'|'center'|'right'|null} align - Column alignment.
13
13
  * @prop {Snippet} [children] - Cell content.
14
14
  */
15
15
  declare const TableCell: import("svelte").Component<Props, {}, "">;
package/dist/types.d.ts CHANGED
@@ -75,7 +75,7 @@ export interface TableRowSnippetProps {
75
75
  }
76
76
  export interface TableCellSnippetProps {
77
77
  header: boolean;
78
- align: 'left' | 'center' | 'right' | 'justify' | 'char' | null | undefined;
78
+ align: 'left' | 'center' | 'right' | null;
79
79
  children?: Snippet;
80
80
  }
81
81
  export interface EmSnippetProps {
@@ -124,8 +124,16 @@ export type SnippetOverrides = {
124
124
  rawtext?: Snippet<[RawTextSnippetProps]>;
125
125
  escape?: Snippet<[EscapeSnippetProps]>;
126
126
  };
127
+ /**
128
+ * Props passed to HTML snippet overrides.
129
+ *
130
+ * **Security note:** `attributes` are spread directly onto the rendered HTML element.
131
+ * This includes any attribute from the source markdown, such as `onclick` or `onerror`.
132
+ * If rendering untrusted markdown, use `allowHtmlOnly`/`excludeHtmlOnly` to restrict
133
+ * allowed tags, or integrate your own sanitizer to strip dangerous attributes.
134
+ */
127
135
  export interface HtmlSnippetProps {
128
- attributes?: Record<string, any>;
136
+ attributes?: Record<string, string | number | boolean | undefined>;
129
137
  children?: Snippet;
130
138
  }
131
139
  export type HtmlSnippetOverrides = {
@@ -182,6 +190,20 @@ export type SvelteMarkdownProps<T extends Renderers = Renderers> = {
182
190
  * @defaultValue `false`
183
191
  */
184
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;
185
207
  /**
186
208
  * Callback invoked after the source has been parsed into tokens.
187
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.
@@ -28,7 +46,7 @@ import type { Token, TokensList } from './markdown-parser.js';
28
46
  * const cachedTokens = parseAndCacheTokens('# Hello World', { gfm: true }, false)
29
47
  * ```
30
48
  */
31
- export declare function parseAndCacheTokens(source: string, options: SvelteMarkdownOptions, isInline: boolean): Token[] | TokensList;
49
+ export declare const parseAndCacheTokens: (source: string, options: SvelteMarkdownOptions, isInline: boolean) => Token[] | TokensList;
32
50
  /**
33
51
  * Parses markdown source with caching (async path).
34
52
  * Uses Marked's recursive walkTokens with Promise.all to properly
@@ -46,4 +64,4 @@ export declare function parseAndCacheTokens(source: string, options: SvelteMarkd
46
64
  * const tokens = await parseAndCacheTokensAsync('# Hello', opts, false)
47
65
  * ```
48
66
  */
49
- export declare function parseAndCacheTokensAsync(source: string, options: SvelteMarkdownOptions, isInline: boolean): Promise<Token[] | TokensList>;
67
+ export declare const parseAndCacheTokensAsync: (source: string, options: SvelteMarkdownOptions, isInline: boolean) => Promise<Token[] | TokensList>;
@@ -10,13 +10,27 @@ import { tokenCache } from './token-cache.js';
10
10
  import { shrinkHtmlTokens } from './token-cleanup.js';
11
11
  import { Lexer, Marked } from 'marked';
12
12
  /**
13
- * Lex and clean tokens from markdown source. Shared by sync and async paths.
13
+ * Lexes markdown source and cleans the resulting tokens. Shared by sync and async paths.
14
+ *
15
+ * @param source - Raw markdown string to lex
16
+ * @param options - Parser options forwarded to the Marked lexer
17
+ * @param isInline - When true, uses inline tokenization (no block elements)
18
+ * @returns Cleaned token array with HTML tokens properly nested
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
+ *
27
+ * @internal
14
28
  */
15
- function lexAndClean(source, options, isInline) {
29
+ export const lexAndClean = (source, options, isInline) => {
16
30
  const lexer = new Lexer(options);
17
31
  const parsedTokens = isInline ? lexer.inlineTokens(source) : lexer.lex(source);
18
32
  return shrinkHtmlTokens(parsedTokens);
19
- }
33
+ };
20
34
  /**
21
35
  * Parses markdown source with caching (synchronous path).
22
36
  * Checks cache first, parses on miss, stores result, and returns tokens.
@@ -37,7 +51,7 @@ function lexAndClean(source, options, isInline) {
37
51
  * const cachedTokens = parseAndCacheTokens('# Hello World', { gfm: true }, false)
38
52
  * ```
39
53
  */
40
- export function parseAndCacheTokens(source, options, isInline) {
54
+ export const parseAndCacheTokens = (source, options, isInline) => {
41
55
  // Check cache first - avoids expensive parsing
42
56
  const cached = tokenCache.getTokens(source, options);
43
57
  if (cached) {
@@ -51,7 +65,7 @@ export function parseAndCacheTokens(source, options, isInline) {
51
65
  // Cache the cleaned tokens for next time
52
66
  tokenCache.setTokens(source, options, cleanedTokens);
53
67
  return cleanedTokens;
54
- }
68
+ };
55
69
  /**
56
70
  * Parses markdown source with caching (async path).
57
71
  * Uses Marked's recursive walkTokens with Promise.all to properly
@@ -69,7 +83,7 @@ export function parseAndCacheTokens(source, options, isInline) {
69
83
  * const tokens = await parseAndCacheTokensAsync('# Hello', opts, false)
70
84
  * ```
71
85
  */
72
- export async function parseAndCacheTokensAsync(source, options, isInline) {
86
+ export const parseAndCacheTokensAsync = async (source, options, isInline) => {
73
87
  // Check cache first - avoids expensive parsing
74
88
  const cached = tokenCache.getTokens(source, options);
75
89
  if (cached) {
@@ -89,4 +103,4 @@ export async function parseAndCacheTokensAsync(source, options, isInline) {
89
103
  // Cache the cleaned tokens for next time
90
104
  tokenCache.setTokens(source, options, cleanedTokens);
91
105
  return cleanedTokens;
92
- }
106
+ };
@@ -35,7 +35,7 @@ import type { Token, TokensList } from './markdown-parser.js';
35
35
  * console.log(hash1 !== hash2) // true - different content = different hash
36
36
  * ```
37
37
  */
38
- declare function hashString(str: string): string;
38
+ declare const hashString: (str: string) => string;
39
39
  /**
40
40
  * Specialized cache for markdown token storage.
41
41
  * Extends MemoryCache with markdown-specific convenience methods.
@@ -33,7 +33,7 @@ import { MemoryCache } from './cache.js';
33
33
  * console.log(hash1 !== hash2) // true - different content = different hash
34
34
  * ```
35
35
  */
36
- function hashString(str) {
36
+ const hashString = (str) => {
37
37
  let hash = 2166136261; // FNV offset basis (32-bit)
38
38
  for (let i = 0; i < str.length; i++) {
39
39
  hash ^= str.charCodeAt(i);
@@ -42,7 +42,7 @@ function hashString(str) {
42
42
  }
43
43
  // Convert to unsigned 32-bit integer and base36 string
44
44
  return (hash >>> 0).toString(36);
45
- }
45
+ };
46
46
  /**
47
47
  * Generates a cache key from markdown source and parser options.
48
48
  * Combines hashes of both source content and options to ensure
@@ -71,7 +71,7 @@ function hashString(str) {
71
71
  // reactivity system creates new objects on prop changes ($derived), so
72
72
  // this is safe for all internal usage paths.
73
73
  const optionsHashCache = new WeakMap();
74
- function getCacheKey(source, options) {
74
+ const getCacheKey = (source, options) => {
75
75
  const sourceHash = hashString(source);
76
76
  let optionsHash = optionsHashCache.get(options);
77
77
  if (!optionsHash) {
@@ -90,7 +90,7 @@ function getCacheKey(source, options) {
90
90
  optionsHashCache.set(options, optionsHash);
91
91
  }
92
92
  return `${sourceHash}:${optionsHash}`;
93
- }
93
+ };
94
94
  /**
95
95
  * Specialized cache for markdown token storage.
96
96
  * Extends MemoryCache with markdown-specific convenience methods.
@@ -32,51 +32,6 @@ export declare const isHtmlOpenTag: (raw: string) => {
32
32
  * @internal
33
33
  */
34
34
  export declare const extractAttributes: (raw: string) => Record<string, string>;
35
- /**
36
- * Converts an HTML string into a sequence of tokens using htmlparser2.
37
- * Handles complex nested structures while maintaining proper order and relationships.
38
- *
39
- * Key features:
40
- * - Preserves original HTML structure without automatic tag closing
41
- * - Handles self-closing tags with proper XML syntax (e.g., <br/> instead of <br>)
42
- * - Gracefully handles malformed HTML by preserving the original structure
43
- * - Maintains attribute information in opening tags
44
- * - Processes text content between tags
45
- *
46
- * @param {string} html - HTML string to be parsed
47
- * @returns {Token[]} Array of tokens representing the HTML structure
48
- *
49
- * @example
50
- * // Well-formed HTML
51
- * parseHtmlBlock('<div>Hello <span>world</span></div>')
52
- * // Returns [
53
- * // { type: 'html', raw: '<div>', ... },
54
- * // { type: 'text', raw: 'Hello ', ... },
55
- * // { type: 'html', raw: '<span>', ... },
56
- * // { type: 'text', raw: 'world', ... },
57
- * // { type: 'html', raw: '</span>', ... },
58
- * // { type: 'html', raw: '</div>', ... }
59
- * // ]
60
- *
61
- * // Self-closing tags
62
- * parseHtmlBlock('<div>Before<br/>After</div>')
63
- * // Returns [
64
- * // { type: 'html', raw: '<div>', ... },
65
- * // { type: 'text', raw: 'Before', ... },
66
- * // { type: 'html', raw: '<br/>', ... },
67
- * // { type: 'text', raw: 'After', ... },
68
- * // { type: 'html', raw: '</div>', ... }
69
- * // ]
70
- *
71
- * // Malformed HTML
72
- * parseHtmlBlock('<div>Unclosed')
73
- * // Returns [
74
- * // { type: 'html', raw: '<div>', ... },
75
- * // { type: 'text', raw: 'Unclosed', ... }
76
- * // ]
77
- *
78
- * @internal
79
- */
80
35
  export declare const parseHtmlBlock: (html: string) => Token[];
81
36
  export declare const containsMultipleTags: (html: string) => boolean;
82
37
  /**
@@ -141,6 +141,22 @@ export const extractAttributes = (raw) => {
141
141
  *
142
142
  * @internal
143
143
  */
144
+ /**
145
+ * Serializes an HTML attribute map into a string for tag construction.
146
+ * Escapes double quotes in values to prevent attribute injection.
147
+ *
148
+ * @param {Record<string, string>} attributes - Map of attribute names to values
149
+ * @returns {string} Serialized attributes string with leading spaces
150
+ *
151
+ * @example
152
+ * serializeAttributes({ class: 'foo', id: 'bar' })
153
+ * // Returns ' class="foo" id="bar"'
154
+ *
155
+ * @internal
156
+ */
157
+ const serializeAttributes = (attributes) => Object.entries(attributes)
158
+ .map(([key, value]) => ` ${key}="${value.replace(/"/g, '&quot;')}"`)
159
+ .join('');
144
160
  export const parseHtmlBlock = (html) => {
145
161
  const tokens = [];
146
162
  let currentText = '';
@@ -158,9 +174,7 @@ export const parseHtmlBlock = (html) => {
158
174
  if (SELF_CLOSING_TAGS.test(name)) {
159
175
  tokens.push({
160
176
  type: 'html',
161
- raw: `<${name}${Object.entries(attributes)
162
- .map(([key, value]) => ` ${key}="${value}"`)
163
- .join('')}/>`,
177
+ raw: `<${name}${serializeAttributes(attributes)}/>`,
164
178
  tag: name,
165
179
  attributes
166
180
  });
@@ -169,9 +183,7 @@ export const parseHtmlBlock = (html) => {
169
183
  openTags.push(name);
170
184
  tokens.push({
171
185
  type: 'html',
172
- raw: `<${name}${Object.entries(attributes)
173
- .map(([key, value]) => ` ${key}="${value}"`)
174
- .join('')}>`,
186
+ raw: `<${name}${serializeAttributes(attributes)}>`,
175
187
  tag: name,
176
188
  attributes
177
189
  });
@@ -203,8 +215,7 @@ export const parseHtmlBlock = (html) => {
203
215
  }
204
216
  }
205
217
  }, {
206
- xmlMode: true,
207
- // Add this to prevent automatic tag closing
218
+ xmlMode: false,
208
219
  recognizeSelfClosing: true
209
220
  });
210
221
  parser.write(html);
@@ -290,19 +301,16 @@ export const shrinkHtmlTokens = (tokens) => {
290
301
  }
291
302
  else if (token.type === 'table') {
292
303
  // Process header cells
293
- if (token.header) {
294
- // @ts-expect-error: expected any
295
- token.header = token.header.map((cell) => ({
304
+ const tableToken = token;
305
+ if (tableToken.header) {
306
+ tableToken.header = tableToken.header.map((cell) => ({
296
307
  ...cell,
297
308
  tokens: cell.tokens ? shrinkHtmlTokens(cell.tokens) : []
298
309
  }));
299
310
  }
300
311
  // Process row cells
301
- if (token.rows) {
302
- // @ts-expect-error: expected any
303
- token.rows = token.rows.map((row) =>
304
- // @ts-expect-error: expected any
305
- row.map((cell) => ({
312
+ if (tableToken.rows) {
313
+ tableToken.rows = tableToken.rows.map((row) => row.map((cell) => ({
306
314
  ...cell,
307
315
  tokens: cell.tokens ? shrinkHtmlTokens(cell.tokens) : []
308
316
  })));
@@ -394,9 +402,9 @@ export const processHtmlTokens = (tokens) => {
394
402
  result.push(token);
395
403
  }
396
404
  }
397
- // If we have unclosed tags, return original tokens
405
+ // If we have unclosed tags, return partial result (better than discarding all work)
398
406
  if (stack.length > 0) {
399
- return tokens;
407
+ return result;
400
408
  }
401
409
  return result;
402
410
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanspeak/svelte-markdown",
3
- "version": "1.0.4",
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.0",
90
- "@typescript-eslint/parser": "^8.57.0",
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": "^28.1.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.11",
109
+ "svelte": "^5.55.0",
110
110
  "svelte-check": "^4.4.5",
111
- "typescript": "^5.9.3",
112
- "typescript-eslint": "^8.57.0",
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",