@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 +61 -2
- package/dist/SvelteMarkdown.svelte +264 -28
- package/dist/SvelteMarkdown.svelte.d.ts +5 -2
- package/dist/index.d.ts +2 -2
- package/dist/types.d.ts +5 -0
- package/dist/utils/incremental-parser.d.ts +9 -4
- package/dist/utils/incremental-parser.js +92 -12
- package/dist/utils/stream-benchmark.d.ts +26 -0
- package/dist/utils/stream-benchmark.js +53 -0
- package/package.json +11 -11
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
lastExtensionsSrc = undefined
|
|
110
|
-
}
|
|
342
|
+
teardownStreamingBuffers()
|
|
343
|
+
if (incrementalParser) clearStreamingParser()
|
|
344
|
+
lastSourceProp = source
|
|
111
345
|
if (streaming && hasAsyncExtension) {
|
|
112
|
-
|
|
113
|
-
'
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
90
|
-
"@typescript-eslint/parser": "^8.
|
|
91
|
-
"@vitest/coverage-v8": "^4.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.
|
|
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.
|
|
110
|
-
"svelte-check": "^4.4.
|
|
109
|
+
"svelte": "^5.55.1",
|
|
110
|
+
"svelte-check": "^4.4.6",
|
|
111
111
|
"typescript": "^6.0.2",
|
|
112
|
-
"typescript-eslint": "^8.
|
|
113
|
-
"vite": "^8.0.
|
|
114
|
-
"vitest": "^4.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",
|