@humanspeak/svelte-markdown 1.2.0 → 1.3.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
@@ -683,11 +683,19 @@ Images automatically lazy load using native `loading="lazy"` and IntersectionObs
683
683
 
684
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
685
 
686
+ The preferred API is now imperative: bind the component instance and call `writeChunk()` as chunks arrive. This avoids prop reactivity edge cases like identical consecutive string chunks being coalesced.
687
+
686
688
  ```svelte
687
689
  <script lang="ts">
688
690
  import SvelteMarkdown from '@humanspeak/svelte-markdown'
691
+ import type { StreamingChunk } from '@humanspeak/svelte-markdown'
689
692
 
690
- let source = $state('')
693
+ let markdown:
694
+ | {
695
+ writeChunk: (chunk: StreamingChunk) => void
696
+ resetStream: (nextSource?: string) => void
697
+ }
698
+ | undefined
691
699
 
692
700
  async function streamResponse() {
693
701
  const response = await fetch('/api/chat', { method: 'POST', body: '...' })
@@ -697,11 +705,62 @@ For real-time rendering of AI responses from ChatGPT, Claude, Gemini, and other
697
705
  while (true) {
698
706
  const { done, value } = await reader.read()
699
707
  if (done) break
700
- source += decoder.decode(value, { stream: true })
708
+ markdown?.writeChunk(decoder.decode(value, { stream: true }))
701
709
  }
702
710
  }
703
711
  </script>
704
712
 
713
+ <SvelteMarkdown bind:this={markdown} source="" streaming={true} />
714
+ ```
715
+
716
+ For websocket-style offset patches, pass an object chunk instead:
717
+
718
+ ```ts
719
+ markdown?.writeChunk({ value: 'world', offset: 6 })
720
+ ```
721
+
722
+ Object chunks overwrite the internal buffer at `offset`. This is overwrite semantics, not insert semantics: the chunk replaces characters starting at that index and preserves any trailing content after the overwritten span.
723
+
724
+ If `offset` skips ahead, missing positions are padded with spaces. There is no delete or truncate behavior in offset mode.
725
+
726
+ Typical websocket-style usage can arrive out of order:
727
+
728
+ ```ts
729
+ markdown?.writeChunk({ value: ' world', offset: 5 })
730
+ markdown?.writeChunk({ value: 'Hello', offset: 0 })
731
+ ```
732
+
733
+ The internal buffer converges as later patches fill earlier gaps.
734
+
735
+ You can reset the internal streaming buffer at any time:
736
+
737
+ ```ts
738
+ markdown?.resetStream('')
739
+ markdown?.resetStream('# Seeded response')
740
+ ```
741
+
742
+ The first successful write after a reset locks the stream into one input mode:
743
+
744
+ - `string` chunks: append mode
745
+ - `{ value, offset }` chunks: offset mode
746
+
747
+ Switching modes before `resetStream()` or a `source` prop reset logs a warning and drops the chunk. Offset chunks must use a non-negative safe integer `offset`.
748
+
749
+ Changing the `source` prop also resets the imperative buffer, seeds a new baseline value, and unlocks the input mode.
750
+
751
+ Appending directly to `source` is still supported:
752
+
753
+ ```svelte
754
+ <script lang="ts">
755
+ import SvelteMarkdown from '@humanspeak/svelte-markdown'
756
+
757
+ let source = $state('')
758
+
759
+ function onChunk(chunk: string) {
760
+ source += chunk
761
+ }
762
+ </script>
763
+
705
764
  <SvelteMarkdown {source} streaming={true} />
706
765
  ```
707
766
 
@@ -50,7 +50,11 @@
50
50
  */
51
51
 
52
52
  import Parser from './Parser.svelte'
53
- import { type SvelteMarkdownProps } from './types.js'
53
+ import {
54
+ type StreamingChunk,
55
+ type StreamingOffsetChunk,
56
+ type SvelteMarkdownProps
57
+ } from './types.js'
54
58
  import { IncrementalParser } from './utils/incremental-parser.js'
55
59
  import {
56
60
  defaultOptions,
@@ -65,6 +69,13 @@
65
69
 
66
70
  // trunk-ignore(eslint/@typescript-eslint/no-explicit-any)
67
71
  type AnySnippet = (..._args: any[]) => any
72
+ type StreamFlushHandle =
73
+ | { kind: 'raf'; id: number }
74
+ | { kind: 'timeout'; id: ReturnType<typeof setTimeout> }
75
+ | null
76
+
77
+ const STREAM_BATCH_FALLBACK_MS = 16
78
+ const STREAM_BATCH_MAX_CHARS = 256
68
79
 
69
80
  const {
70
81
  source = [],
@@ -99,51 +110,270 @@
99
110
  let incrementalParser: IncrementalParser | undefined
100
111
  let lastOptionsSrc: typeof options | undefined
101
112
  let lastExtensionsSrc: typeof extensions | undefined
113
+ let lastSourceProp: typeof source | undefined
114
+ let streamSourceBuffer = ''
115
+ let pendingStreamAppendBuffer = ''
116
+ let streamFlushHandle: StreamFlushHandle = null
117
+ let streamInputMode: 'append' | 'offset' | null = null
102
118
  let streamTokens = $state<Token[]>([])
103
119
 
120
+ const warnStreaming = (message: string) => {
121
+ console.warn(`[svelte-markdown] ${message}`)
122
+ }
123
+
124
+ const clearStreamingParser = () => {
125
+ incrementalParser = undefined
126
+ lastOptionsSrc = undefined
127
+ lastExtensionsSrc = undefined
128
+ }
129
+
130
+ const cancelScheduledAppendFlush = () => {
131
+ if (!streamFlushHandle) return
132
+
133
+ if (streamFlushHandle.kind === 'raf' && typeof cancelAnimationFrame === 'function') {
134
+ cancelAnimationFrame(streamFlushHandle.id)
135
+ } else if (streamFlushHandle.kind === 'timeout') {
136
+ clearTimeout(streamFlushHandle.id)
137
+ }
138
+
139
+ streamFlushHandle = null
140
+ }
141
+
142
+ const hasStreamingParserConfigChanged = () =>
143
+ !incrementalParser || lastOptionsSrc !== options || lastExtensionsSrc !== extensions
144
+
145
+ const applyStreamingSource = (nextSource: string, forceNewParser = false) => {
146
+ if (forceNewParser || hasStreamingParserConfigChanged()) {
147
+ incrementalParser = new IncrementalParser(combinedOptions)
148
+ lastOptionsSrc = options
149
+ lastExtensionsSrc = extensions
150
+ }
151
+
152
+ const parser = incrementalParser
153
+ if (!parser) return
154
+
155
+ const { tokens: newTokens, divergeAt } = parser.update(nextSource)
156
+
157
+ for (let i = divergeAt; i < newTokens.length; i++) {
158
+ streamTokens[i] = newTokens[i]
159
+ }
160
+ streamTokens.length = newTokens.length
161
+ }
162
+
163
+ const commitPendingAppendBuffer = () => {
164
+ if (pendingStreamAppendBuffer === '') return false
165
+
166
+ streamSourceBuffer += pendingStreamAppendBuffer
167
+ pendingStreamAppendBuffer = ''
168
+
169
+ return true
170
+ }
171
+
172
+ const flushPendingAppendChunks = (forceNewParser = false) => {
173
+ cancelScheduledAppendFlush()
174
+
175
+ if (!commitPendingAppendBuffer()) return
176
+
177
+ applyStreamingSource(streamSourceBuffer, forceNewParser)
178
+ }
179
+
180
+ const scheduleAppendFlush = () => {
181
+ if (streamFlushHandle || pendingStreamAppendBuffer === '') return
182
+
183
+ if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
184
+ const id = window.requestAnimationFrame(() => {
185
+ streamFlushHandle = null
186
+ flushPendingAppendChunks()
187
+ })
188
+ streamFlushHandle = { kind: 'raf', id }
189
+ return
190
+ }
191
+
192
+ const id = setTimeout(() => {
193
+ streamFlushHandle = null
194
+ flushPendingAppendChunks()
195
+ }, STREAM_BATCH_FALLBACK_MS)
196
+ streamFlushHandle = { kind: 'timeout', id }
197
+ }
198
+
199
+ const teardownStreamingBuffers = () => {
200
+ cancelScheduledAppendFlush()
201
+ pendingStreamAppendBuffer = ''
202
+ streamInputMode = null
203
+ streamSourceBuffer = ''
204
+ }
205
+
206
+ const resetStreamingState = (nextSource = '') => {
207
+ teardownStreamingBuffers()
208
+ streamSourceBuffer = nextSource
209
+
210
+ if (nextSource === '') {
211
+ clearStreamingParser()
212
+ streamTokens.length = 0
213
+ return
214
+ }
215
+
216
+ applyStreamingSource(nextSource, true)
217
+ }
218
+
219
+ const syncStreamingSourceFromProp = (nextSource: typeof source) => {
220
+ lastSourceProp = nextSource
221
+
222
+ if (Array.isArray(nextSource)) {
223
+ teardownStreamingBuffers()
224
+ clearStreamingParser()
225
+ streamTokens = [...(nextSource as Token[])]
226
+ return
227
+ }
228
+
229
+ resetStreamingState(nextSource as string)
230
+ }
231
+
232
+ const canUseImperativeStreaming = (methodName: 'writeChunk' | 'resetStream'): boolean => {
233
+ if (!streaming) {
234
+ warnStreaming(`${methodName}() is only available when streaming={true}; call dropped.`)
235
+ return false
236
+ }
237
+
238
+ if (hasAsyncExtension) {
239
+ warnStreaming(
240
+ `${methodName}() is unavailable when async extensions are used; call dropped.`
241
+ )
242
+ return false
243
+ }
244
+
245
+ if (Array.isArray(source)) {
246
+ warnStreaming(
247
+ `${methodName}() requires a string-backed source; token array sources are not supported.`
248
+ )
249
+ return false
250
+ }
251
+
252
+ return true
253
+ }
254
+
255
+ const applyAppendChunk = (value: string) => {
256
+ pendingStreamAppendBuffer += value
257
+
258
+ if (pendingStreamAppendBuffer.length >= STREAM_BATCH_MAX_CHARS) {
259
+ flushPendingAppendChunks()
260
+ return
261
+ }
262
+
263
+ scheduleAppendFlush()
264
+ }
265
+
266
+ const applyOffsetChunk = ({ value, offset }: StreamingOffsetChunk) => {
267
+ const padded =
268
+ offset > streamSourceBuffer.length
269
+ ? streamSourceBuffer + ' '.repeat(offset - streamSourceBuffer.length)
270
+ : streamSourceBuffer
271
+ const prefix = padded.slice(0, offset)
272
+ const suffix = padded.slice(offset + value.length)
273
+
274
+ streamSourceBuffer = prefix + value + suffix
275
+ applyStreamingSource(streamSourceBuffer)
276
+ }
277
+
278
+ const isStreamingOffsetChunk = (chunk: StreamingChunk): chunk is StreamingOffsetChunk =>
279
+ typeof chunk === 'object' && chunk !== null && 'offset' in chunk
280
+
281
+ export function writeChunk(chunk: StreamingChunk): void {
282
+ if (!canUseImperativeStreaming('writeChunk')) return
283
+
284
+ if (typeof chunk === 'string') {
285
+ if (streamInputMode === 'offset') {
286
+ warnStreaming(
287
+ 'offset mode active, string chunk dropped. Call resetStream() before switching streaming input modes.'
288
+ )
289
+ return
290
+ }
291
+
292
+ if (streamInputMode === null) {
293
+ streamInputMode = 'append'
294
+ }
295
+
296
+ applyAppendChunk(chunk)
297
+ return
298
+ }
299
+
300
+ if (!isStreamingOffsetChunk(chunk) || typeof chunk.value !== 'string') {
301
+ warnStreaming(
302
+ 'Invalid chunk object passed to writeChunk(); expected { value: string, offset: number }.'
303
+ )
304
+ return
305
+ }
306
+
307
+ if (!Number.isSafeInteger(chunk.offset) || chunk.offset < 0) {
308
+ warnStreaming(
309
+ 'Invalid offset chunk passed to writeChunk(); offset must be a non-negative safe integer.'
310
+ )
311
+ return
312
+ }
313
+
314
+ if (streamInputMode === 'append') {
315
+ warnStreaming(
316
+ 'append mode active, offset chunk dropped. Call resetStream() before switching streaming input modes.'
317
+ )
318
+ return
319
+ }
320
+
321
+ if (streamInputMode === null) {
322
+ streamInputMode = 'offset'
323
+ }
324
+
325
+ applyOffsetChunk(chunk)
326
+ }
327
+
328
+ export function resetStream(nextSource = ''): void {
329
+ if (!canUseImperativeStreaming('resetStream')) return
330
+
331
+ resetStreamingState(nextSource)
332
+ }
333
+
334
+ $effect(() => {
335
+ return () => {
336
+ cancelScheduledAppendFlush()
337
+ }
338
+ })
339
+
104
340
  $effect(() => {
105
341
  if (!streaming || hasAsyncExtension) {
106
- if (incrementalParser) {
107
- incrementalParser = undefined
108
- lastOptionsSrc = undefined
109
- lastExtensionsSrc = undefined
110
- }
342
+ teardownStreamingBuffers()
343
+ if (incrementalParser) clearStreamingParser()
344
+ lastSourceProp = source
111
345
  if (streaming && hasAsyncExtension) {
112
- console.warn(
113
- '[svelte-markdown] streaming prop is ignored when async extensions are used. ' +
346
+ warnStreaming(
347
+ 'streaming prop is ignored when async extensions are used. ' +
114
348
  'Remove async extensions or set streaming={false} to silence this warning.'
115
349
  )
116
350
  }
117
351
  return
118
352
  }
119
353
 
120
- // Read combinedOptions so Svelte tracks it as a dependency
121
- const currentOptions = combinedOptions
122
-
123
- if (Array.isArray(source)) {
124
- streamTokens = source as Token[]
354
+ if (lastSourceProp !== source) {
355
+ syncStreamingSourceFromProp(source)
125
356
  return
126
357
  }
127
358
 
128
- if (source === '') {
129
- if (incrementalParser) incrementalParser.reset()
130
- streamTokens.length = 0
359
+ if (Array.isArray(source)) {
131
360
  return
132
361
  }
133
362
 
134
- // Recreate parser when user-facing options or extensions change
135
- if (!incrementalParser || lastOptionsSrc !== options || lastExtensionsSrc !== extensions) {
136
- incrementalParser = new IncrementalParser(currentOptions)
137
- lastOptionsSrc = options
138
- lastExtensionsSrc = extensions
139
- }
140
- const { tokens: newTokens, divergeAt } = incrementalParser.update(source as string)
363
+ if (hasStreamingParserConfigChanged()) {
364
+ if (pendingStreamAppendBuffer !== '') {
365
+ flushPendingAppendChunks(true)
366
+ return
367
+ }
141
368
 
142
- // In-place update: only touch changed/appended indices
143
- for (let i = divergeAt; i < newTokens.length; i++) {
144
- streamTokens[i] = newTokens[i]
369
+ if (streamSourceBuffer === '') {
370
+ clearStreamingParser()
371
+ streamTokens.length = 0
372
+ return
373
+ }
374
+
375
+ applyStreamingSource(streamSourceBuffer, true)
145
376
  }
146
- streamTokens.length = newTokens.length
147
377
  })
148
378
 
149
379
  // Synchronous token derivation (default fast path — non-streaming)
@@ -204,7 +434,13 @@
204
434
  })
205
435
 
206
436
  // Unified tokens: streaming > sync > async
207
- const tokens = $derived(streaming ? streamTokens : hasAsyncExtension ? asyncTokens : syncTokens)
437
+ const tokens = $derived(
438
+ streaming && !hasAsyncExtension
439
+ ? streamTokens
440
+ : hasAsyncExtension
441
+ ? asyncTokens
442
+ : syncTokens
443
+ )
208
444
 
209
445
  $effect(() => {
210
446
  if (!tokens) return
@@ -1,4 +1,4 @@
1
- import { type SvelteMarkdownProps } from './types.js';
1
+ import { type StreamingChunk, type SvelteMarkdownProps } from './types.js';
2
2
  type $$ComponentProps = SvelteMarkdownProps & {
3
3
  [key: string]: unknown;
4
4
  };
@@ -24,6 +24,9 @@ type $$ComponentProps = SvelteMarkdownProps & {
24
24
  * @property {boolean} [isInline=false] - Whether to parse the content as inline markdown
25
25
  * @property {function} [parsed] - Callback function called with the parsed tokens
26
26
  */
27
- declare const SvelteMarkdown: import("svelte").Component<$$ComponentProps, {}, "">;
27
+ declare const SvelteMarkdown: import("svelte").Component<$$ComponentProps, {
28
+ writeChunk: (chunk: StreamingChunk) => void;
29
+ resetStream: (nextSource?: string) => void;
30
+ }, "">;
28
31
  type SvelteMarkdown = ReturnType<typeof SvelteMarkdown>;
29
32
  export default SvelteMarkdown;
package/dist/index.d.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { type HtmlRenderers } from './renderers/html/index.js';
19
19
  import SvelteMarkdown from './SvelteMarkdown.svelte';
20
- import type { BlockquoteSnippetProps, BrSnippetProps, CodeSnippetProps, CodespanSnippetProps, DelSnippetProps, EmSnippetProps, EscapeSnippetProps, HeadingSnippetProps, HrSnippetProps, HtmlSnippetOverrides, HtmlSnippetProps, ImageSnippetProps, LinkSnippetProps, ListItemSnippetProps, ListSnippetProps, ParagraphSnippetProps, RawTextSnippetProps, SnippetOverrides, StrongSnippetProps, SvelteMarkdownOptions, SvelteMarkdownProps, TableBodySnippetProps, TableCellSnippetProps, TableHeadSnippetProps, TableRowSnippetProps, TableSnippetProps, TextSnippetProps } from './types.js';
20
+ import type { BlockquoteSnippetProps, BrSnippetProps, CodeSnippetProps, CodespanSnippetProps, DelSnippetProps, EmSnippetProps, EscapeSnippetProps, HeadingSnippetProps, HrSnippetProps, HtmlSnippetOverrides, HtmlSnippetProps, ImageSnippetProps, LinkSnippetProps, ListItemSnippetProps, ListSnippetProps, ParagraphSnippetProps, RawTextSnippetProps, SnippetOverrides, StreamingChunk, StreamingOffsetChunk, StrongSnippetProps, SvelteMarkdownOptions, SvelteMarkdownProps, TableBodySnippetProps, TableCellSnippetProps, TableHeadSnippetProps, TableRowSnippetProps, TableSnippetProps, TextSnippetProps } from './types.js';
21
21
  import { defaultRenderers, type RendererComponent, type Renderers, type Token, type TokensList } from './utils/markdown-parser.js';
22
22
  /** The primary markdown rendering component. */
23
23
  export default SvelteMarkdown;
@@ -63,4 +63,4 @@ export { TokenCache, tokenCache } from './utils/token-cache.js';
63
63
  /** Re-exported `MarkedExtension` type for the `extensions` prop. */
64
64
  export type { MarkedExtension } from 'marked';
65
65
  /** Re-exported types for consumer convenience. */
66
- export type { BlockquoteSnippetProps, BrSnippetProps, CodeSnippetProps, CodespanSnippetProps, DelSnippetProps, EmSnippetProps, EscapeSnippetProps, HeadingSnippetProps, HrSnippetProps, HtmlRenderers, HtmlSnippetOverrides, HtmlSnippetProps, ImageSnippetProps, LinkSnippetProps, ListItemSnippetProps, ListSnippetProps, ParagraphSnippetProps, RawTextSnippetProps, RendererComponent, Renderers, SnippetOverrides, StrongSnippetProps, SvelteMarkdownOptions, SvelteMarkdownProps, TableBodySnippetProps, TableCellSnippetProps, TableHeadSnippetProps, TableRowSnippetProps, TableSnippetProps, TextSnippetProps, Token, TokensList };
66
+ export type { BlockquoteSnippetProps, BrSnippetProps, CodeSnippetProps, CodespanSnippetProps, DelSnippetProps, EmSnippetProps, EscapeSnippetProps, HeadingSnippetProps, HrSnippetProps, HtmlRenderers, HtmlSnippetOverrides, HtmlSnippetProps, ImageSnippetProps, LinkSnippetProps, ListItemSnippetProps, ListSnippetProps, ParagraphSnippetProps, RawTextSnippetProps, RendererComponent, Renderers, SnippetOverrides, StreamingChunk, StreamingOffsetChunk, StrongSnippetProps, SvelteMarkdownOptions, SvelteMarkdownProps, TableBodySnippetProps, TableCellSnippetProps, TableHeadSnippetProps, TableRowSnippetProps, TableSnippetProps, TextSnippetProps, Token, TokensList };
package/dist/types.d.ts CHANGED
@@ -139,6 +139,11 @@ export interface HtmlSnippetProps {
139
139
  export type HtmlSnippetOverrides = {
140
140
  [K in HtmlKey as `html_${K}`]?: Snippet<[HtmlSnippetProps]>;
141
141
  };
142
+ export interface StreamingOffsetChunk {
143
+ value: string;
144
+ offset: number;
145
+ }
146
+ export type StreamingChunk = string | StreamingOffsetChunk;
142
147
  export type SvelteMarkdownProps<T extends Renderers = Renderers> = {
143
148
  /**
144
149
  * Markdown content to render.
@@ -43,14 +43,23 @@ export interface IncrementalUpdateResult {
43
43
  export declare class IncrementalParser {
44
44
  /** Previous parse result for diffing */
45
45
  private prevTokens;
46
+ /** Previous full source string for append-only tail reparsing */
47
+ private prevSource;
46
48
  /** Parser options passed to the Marked lexer */
47
49
  private options;
50
+ /** Whether caller-supplied parser hooks make tail-window reparsing unsafe */
51
+ private tailWindowDisabled;
48
52
  /**
49
53
  * Creates a new incremental parser instance.
50
54
  *
51
55
  * @param options - Svelte markdown parser options forwarded to Marked's Lexer
52
56
  */
53
57
  constructor(options: SvelteMarkdownOptions);
58
+ private getTailWindowBoundary;
59
+ private isStableAtSourceEnd;
60
+ private hasAppendSensitiveReferenceSyntax;
61
+ private canUseTailWindow;
62
+ private parseSource;
54
63
  /**
55
64
  * Parses the full source and diffs against the previous result.
56
65
  *
@@ -58,8 +67,4 @@ export declare class IncrementalParser {
58
67
  * @returns The new tokens and the index where they diverge from the previous parse
59
68
  */
60
69
  update: (source: string) => IncrementalUpdateResult;
61
- /**
62
- * Resets the parser state. Call this when starting a new stream.
63
- */
64
- reset: () => void;
65
70
  }
@@ -9,6 +9,10 @@
9
9
  * @module incremental-parser
10
10
  */
11
11
  import { lexAndClean } from './parse-and-cache.js';
12
+ const CLOSED_FENCE_RE = /^ {0,3}(`{3,}|~{3,}).*\n[\s\S]*\n {0,3}\1[ \t]*\n*$/;
13
+ const LINK_REFERENCE_RE = /\[[^\]\n]+\]\[[^\]\n]*\]/;
14
+ const SHORTCUT_REFERENCE_RE = /\[[^\]\n]+\](?![[(])/; // Excludes inline links/images and full refs
15
+ const REFERENCE_DEFINITION_RE = /^\s{0,3}\[[^\]\n]+\]:/m;
12
16
  /**
13
17
  * Streaming-optimized parser that performs full re-parses but diffs results
14
18
  * against the previous token array to minimize DOM updates.
@@ -33,8 +37,12 @@ import { lexAndClean } from './parse-and-cache.js';
33
37
  export class IncrementalParser {
34
38
  /** Previous parse result for diffing */
35
39
  prevTokens = [];
40
+ /** Previous full source string for append-only tail reparsing */
41
+ prevSource = '';
36
42
  /** Parser options passed to the Marked lexer */
37
43
  options;
44
+ /** Whether caller-supplied parser hooks make tail-window reparsing unsafe */
45
+ tailWindowDisabled;
38
46
  /**
39
47
  * Creates a new incremental parser instance.
40
48
  *
@@ -42,7 +50,77 @@ export class IncrementalParser {
42
50
  */
43
51
  constructor(options) {
44
52
  this.options = options;
53
+ const exts = options.extensions;
54
+ const hasExtensionTokenizers = (exts?.block != null && exts.block.length > 0) ||
55
+ (exts?.inline != null && exts.inline.length > 0);
56
+ this.tailWindowDisabled =
57
+ typeof options.walkTokens === 'function' ||
58
+ options.tokenizer != null ||
59
+ hasExtensionTokenizers;
45
60
  }
61
+ getTailWindowBoundary = () => {
62
+ if (this.prevTokens.length === 0) {
63
+ return { prefixCount: 0, reparseOffset: 0 };
64
+ }
65
+ let offset = 0;
66
+ for (let i = 0; i < this.prevTokens.length - 1; i++) {
67
+ offset += this.prevTokens[i].raw.length;
68
+ }
69
+ const lastToken = this.prevTokens[this.prevTokens.length - 1];
70
+ if (this.isStableAtSourceEnd(lastToken)) {
71
+ return {
72
+ prefixCount: this.prevTokens.length,
73
+ reparseOffset: this.prevSource.length
74
+ };
75
+ }
76
+ return {
77
+ prefixCount: this.prevTokens.length - 1,
78
+ reparseOffset: offset
79
+ };
80
+ };
81
+ isStableAtSourceEnd = (token) => {
82
+ if (token.type === 'space')
83
+ return false;
84
+ if (token.raw.endsWith('\n\n'))
85
+ return true;
86
+ switch (token.type) {
87
+ case 'heading':
88
+ case 'hr':
89
+ return token.raw.endsWith('\n');
90
+ case 'code':
91
+ return CLOSED_FENCE_RE.test(token.raw);
92
+ default:
93
+ return false;
94
+ }
95
+ };
96
+ hasAppendSensitiveReferenceSyntax = (source) => {
97
+ if (!source.includes('[') || !source.includes(']'))
98
+ return false;
99
+ return (LINK_REFERENCE_RE.test(source) ||
100
+ SHORTCUT_REFERENCE_RE.test(source) ||
101
+ REFERENCE_DEFINITION_RE.test(source));
102
+ };
103
+ canUseTailWindow = (source, boundary) => {
104
+ if (this.tailWindowDisabled)
105
+ return false;
106
+ if (this.prevSource === '' || this.prevTokens.length === 0)
107
+ return false;
108
+ if (!source.startsWith(this.prevSource))
109
+ return false;
110
+ if (boundary.reparseOffset <= 0)
111
+ return false;
112
+ const stablePrefix = this.prevSource.slice(0, boundary.reparseOffset);
113
+ if (this.hasAppendSensitiveReferenceSyntax(stablePrefix))
114
+ return false;
115
+ return true;
116
+ };
117
+ parseSource = (source, boundary) => {
118
+ if (!this.canUseTailWindow(source, boundary)) {
119
+ return lexAndClean(source, this.options, false);
120
+ }
121
+ const tailTokens = lexAndClean(source.slice(boundary.reparseOffset), this.options, false);
122
+ return [...this.prevTokens.slice(0, boundary.prefixCount), ...tailTokens];
123
+ };
46
124
  /**
47
125
  * Parses the full source and diffs against the previous result.
48
126
  *
@@ -50,26 +128,28 @@ export class IncrementalParser {
50
128
  * @returns The new tokens and the index where they diverge from the previous parse
51
129
  */
52
130
  update = (source) => {
53
- const newTokens = lexAndClean(source, this.options, false);
131
+ const boundary = this.getTailWindowBoundary();
132
+ const newTokens = this.parseSource(source, boundary);
54
133
  // Apply walkTokens if configured
55
134
  if (typeof this.options.walkTokens === 'function') {
56
135
  newTokens.forEach(this.options.walkTokens);
57
136
  }
137
+ // Reference definitions can change inline children without changing raw,
138
+ // so force a full rerender when reference syntax is present
139
+ const referenceSensitive = this.hasAppendSensitiveReferenceSyntax(this.prevSource) ||
140
+ this.hasAppendSensitiveReferenceSyntax(source);
58
141
  // Find first divergence point by comparing raw strings
59
142
  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++;
143
+ if (!referenceSensitive) {
144
+ const minLen = Math.min(this.prevTokens.length, newTokens.length);
145
+ while (divergeAt < minLen) {
146
+ if (this.prevTokens[divergeAt].raw !== newTokens[divergeAt].raw)
147
+ break;
148
+ divergeAt++;
149
+ }
65
150
  }
151
+ this.prevSource = source;
66
152
  this.prevTokens = newTokens;
67
153
  return { tokens: newTokens, divergeAt };
68
154
  };
69
- /**
70
- * Resets the parser state. Call this when starting a new stream.
71
- */
72
- reset = () => {
73
- this.prevTokens = [];
74
- };
75
155
  }
@@ -0,0 +1,26 @@
1
+ import type { SvelteMarkdownOptions } from '../types.js';
2
+ import type { Token } from './markdown-parser.js';
3
+ export interface StreamBenchmarkResult {
4
+ totalChars: number;
5
+ chunkCount: number;
6
+ totalParseMs: number;
7
+ peakParseMs: number;
8
+ p95ParseMs: number;
9
+ finalTokens: Token[];
10
+ parseDurationsMs: number[];
11
+ }
12
+ /**
13
+ * Benchmarks incremental parsing performance by simulating streaming chunk appends.
14
+ *
15
+ * @param chunks - Array of string chunks to append sequentially
16
+ * @param options - SvelteMarkdown parser options forwarded to IncrementalParser
17
+ * @returns Benchmark results including per-chunk timing, peak, and p95 parse durations
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * const chunks = ['# Hello ', 'world, ', 'this is a test.']
22
+ * const result = benchmarkAppendStream(chunks, { gfm: true })
23
+ * console.log(result.p95ParseMs, result.peakParseMs)
24
+ * ```
25
+ */
26
+ export declare const benchmarkAppendStream: (chunks: string[], options: SvelteMarkdownOptions) => StreamBenchmarkResult;
@@ -0,0 +1,53 @@
1
+ import { IncrementalParser } from './incremental-parser.js';
2
+ /**
3
+ * Calculates the p-th percentile of a numeric array.
4
+ *
5
+ * @param values - Array of numeric values
6
+ * @param p - Percentile to calculate (0-1, e.g., 0.95 for 95th percentile)
7
+ * @returns The value at the specified percentile, or 0 if array is empty
8
+ */
9
+ const percentile = (values, p) => {
10
+ if (values.length === 0)
11
+ return 0;
12
+ const sorted = [...values].sort((a, b) => a - b);
13
+ const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * p) - 1));
14
+ return sorted[index];
15
+ };
16
+ /**
17
+ * Benchmarks incremental parsing performance by simulating streaming chunk appends.
18
+ *
19
+ * @param chunks - Array of string chunks to append sequentially
20
+ * @param options - SvelteMarkdown parser options forwarded to IncrementalParser
21
+ * @returns Benchmark results including per-chunk timing, peak, and p95 parse durations
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * const chunks = ['# Hello ', 'world, ', 'this is a test.']
26
+ * const result = benchmarkAppendStream(chunks, { gfm: true })
27
+ * console.log(result.p95ParseMs, result.peakParseMs)
28
+ * ```
29
+ */
30
+ export const benchmarkAppendStream = (chunks, options) => {
31
+ const parser = new IncrementalParser(options);
32
+ const parseDurationsMs = [];
33
+ let source = '';
34
+ let finalTokens = [];
35
+ for (const chunk of chunks) {
36
+ source += chunk;
37
+ const start = performance.now();
38
+ const result = parser.update(source);
39
+ const elapsed = performance.now() - start;
40
+ parseDurationsMs.push(elapsed);
41
+ finalTokens = result.tokens;
42
+ }
43
+ const totalParseMs = parseDurationsMs.reduce((sum, duration) => sum + duration, 0);
44
+ return {
45
+ totalChars: source.length,
46
+ chunkCount: chunks.length,
47
+ totalParseMs,
48
+ peakParseMs: parseDurationsMs.length > 0 ? Math.max(...parseDurationsMs) : 0,
49
+ p95ParseMs: percentile(parseDurationsMs, 0.95),
50
+ finalTokens,
51
+ parseDurationsMs
52
+ };
53
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanspeak/svelte-markdown",
3
- "version": "1.2.0",
3
+ "version": "1.3.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",
@@ -75,7 +75,7 @@
75
75
  "devDependencies": {
76
76
  "@eslint/compat": "^2.0.3",
77
77
  "@eslint/js": "^10.0.1",
78
- "@playwright/cli": "^0.1.1",
78
+ "@playwright/cli": "^0.1.2",
79
79
  "@playwright/test": "^1.58.2",
80
80
  "@sveltejs/adapter-auto": "^7.0.1",
81
81
  "@sveltejs/kit": "^2.55.0",
@@ -86,9 +86,9 @@
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.2",
90
- "@typescript-eslint/parser": "^8.57.2",
91
- "@vitest/coverage-v8": "^4.1.1",
89
+ "@typescript-eslint/eslint-plugin": "^8.58.0",
90
+ "@typescript-eslint/parser": "^8.58.0",
91
+ "@vitest/coverage-v8": "^4.1.2",
92
92
  "eslint": "^10.1.0",
93
93
  "eslint-config-prettier": "^10.1.8",
94
94
  "eslint-plugin-import": "^2.32.0",
@@ -97,7 +97,7 @@
97
97
  "globals": "^17.4.0",
98
98
  "husky": "^9.1.7",
99
99
  "jsdom": "^29.0.1",
100
- "katex": "^0.16.41",
100
+ "katex": "^0.16.44",
101
101
  "marked-katex-extension": "^5.1.7",
102
102
  "mermaid": "^11.13.0",
103
103
  "mprocs": "^0.9.2",
@@ -106,12 +106,12 @@
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.55.0",
110
- "svelte-check": "^4.4.5",
109
+ "svelte": "^5.55.1",
110
+ "svelte-check": "^4.4.6",
111
111
  "typescript": "^6.0.2",
112
- "typescript-eslint": "^8.57.2",
113
- "vite": "^8.0.2",
114
- "vitest": "^4.1.1"
112
+ "typescript-eslint": "^8.58.0",
113
+ "vite": "^8.0.3",
114
+ "vitest": "^4.1.2"
115
115
  },
116
116
  "peerDependencies": {
117
117
  "mermaid": ">=10.0.0",