markstream-svelte 0.0.1-beta.4 → 0.0.1-beta.5

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.
@@ -1,13 +1,65 @@
1
1
  <script lang="ts">
2
- import type { SvelteRenderableNode } from './shared/node-helpers'
2
+ import type { SvelteRenderableNode, SvelteRenderContext } from './shared/node-helpers'
3
+ import { resolveStreamingTextState } from 'markstream-core'
3
4
  import { getString } from './shared/node-helpers'
4
-
5
+
5
6
  interface Props {
6
- node: SvelteRenderableNode;
7
+ node: SvelteRenderableNode
8
+ context?: SvelteRenderContext
9
+ indexKey?: string | number
7
10
  }
8
-
9
- let { node }: Props = $props();
10
-
11
- let code = $derived(getString((node as any)?.code ?? (node as any)?.content ?? (node as any)?.raw));
11
+
12
+ let {
13
+ node,
14
+ context = undefined,
15
+ indexKey = undefined,
16
+ }: Props = $props()
17
+
18
+ let previousKey = ''
19
+ let previousCode = ''
20
+ let deltaClass = 'markstream-svelte-text__stream-delta--a'
21
+
22
+ const code = $derived(getString((node as any)?.code ?? (node as any)?.content ?? (node as any)?.raw))
23
+ const streamKey = $derived(
24
+ `${String(context?.customId ?? 'global')}:${String(context?.streamRenderVersion ?? 0)}:${String(indexKey ?? 'inline-code')}`,
25
+ )
26
+ const fadeEnabled = $derived(context?.fade !== false)
27
+
28
+ const streamInfo = $derived.by(() => {
29
+ const state = context?.textStreamState
30
+ const previous = streamKey === previousKey
31
+ ? previousCode
32
+ : (state?.get(streamKey) ?? '')
33
+
34
+ const result = resolveStreamingTextState({
35
+ nextContent: code,
36
+ previousContent: previous,
37
+ typewriterEnabled: fadeEnabled,
38
+ })
39
+
40
+ if (result.appended) {
41
+ deltaClass = deltaClass.endsWith('--a')
42
+ ? 'markstream-svelte-text__stream-delta--b'
43
+ : 'markstream-svelte-text__stream-delta--a'
44
+ }
45
+
46
+ previousKey = streamKey
47
+ previousCode = code
48
+ state?.set(streamKey, code)
49
+
50
+ return {
51
+ stableCode: result.settledContent,
52
+ deltaCode: result.streamedDelta,
53
+ deltaClass,
54
+ }
55
+ })
12
56
  </script>
13
- <code class="inline-code-node">{code}</code>
57
+
58
+ <code class="inline-code-node">
59
+ {streamInfo.stableCode}
60
+ {#if streamInfo.deltaCode}
61
+ <span class="markstream-svelte-text__stream-delta text-node-stream-delta {streamInfo.deltaClass}">
62
+ {streamInfo.deltaCode}
63
+ </span>
64
+ {/if}
65
+ </code>
@@ -1,6 +1,8 @@
1
- import type { SvelteRenderableNode } from './shared/node-helpers';
1
+ import type { SvelteRenderableNode, SvelteRenderContext } from './shared/node-helpers';
2
2
  interface Props {
3
3
  node: SvelteRenderableNode;
4
+ context?: SvelteRenderContext;
5
+ indexKey?: string | number;
4
6
  }
5
7
  declare const InlineCodeNode: import("svelte").Component<Props, {}, "">;
6
8
  type InlineCodeNode = ReturnType<typeof InlineCodeNode>;
@@ -103,6 +103,7 @@
103
103
  isDark={context?.isDark}
104
104
  indexKey={indexKey}
105
105
  typewriter={context?.typewriter}
106
+ fade={context?.fade}
106
107
  {...customInputs}
107
108
  />
108
109
  {:else if resolvedType === 'text' || resolvedType === 'text_special'}
@@ -136,7 +137,7 @@
136
137
  {:else if resolvedType === 'image'}
137
138
  <ImageNode {node} />
138
139
  {:else if resolvedType === 'inline_code'}
139
- <InlineCodeNode {node} />
140
+ <InlineCodeNode {node} {context} {indexKey} />
140
141
  {:else if resolvedType === 'strong'}
141
142
  <StrongNode {node} {context} {indexKey} />
142
143
  {:else if resolvedType === 'emphasis'}
@@ -5,11 +5,14 @@
5
5
  SvelteRenderableNode,
6
6
  SvelteRenderContext,
7
7
  } from './shared/node-helpers'
8
- import { onDestroy, onMount, tick } from 'svelte'
8
+ import { getContext, onDestroy, onMount, setContext, tick } from 'svelte'
9
9
  import { getCustomNodeComponents, subscribeCustomComponents } from '../customComponents'
10
10
  import { disposeRenderedHtmlEnhancements, enhanceRenderedHtml } from '../enhanceRenderedHtml'
11
11
  import NodeOutlet from './NodeOutlet.svelte'
12
12
  import { buildRenderContext, resolveParsedNodes } from './shared/node-helpers'
13
+ import { useSmoothMarkdownStream } from '../composables/useSmoothMarkdownStream.svelte'
14
+ import { SMOOTH_STREAMING_CONTEXT } from '../context/smoothStreaming'
15
+ import type { SmoothStreamingContextValue } from '../context/smoothStreaming'
13
16
 
14
17
  type NodeRendererComponentProps = NodeRendererProps & NodeRendererEvents & {
15
18
  className?: string
@@ -45,7 +48,8 @@
45
48
  isDark = false,
46
49
  customId = undefined,
47
50
  indexKey = undefined,
48
- typewriter = true,
51
+ typewriter = false,
52
+ fade = true,
49
53
  batchRendering = true,
50
54
  initialRenderBatchSize = 40,
51
55
  renderBatchSize = 80,
@@ -56,6 +60,8 @@
56
60
  maxLiveNodes = 320,
57
61
  liveNodeBuffer = 60,
58
62
  allowHtml = true,
63
+ smoothStreaming = 'auto' as boolean | 'auto',
64
+ smoothStreamingOptions = undefined,
59
65
  className = '',
60
66
  onCopy = undefined,
61
67
  onHandleArtifactClick = undefined,
@@ -78,10 +84,91 @@
78
84
  let renderBatchToken = 0
79
85
  const textStreamState = new Map<string, string>()
80
86
 
87
+ const smoothStream = useSmoothMarkdownStream(smoothStreamingOptions)
88
+ let hasMountedForSmoothStreaming = $state(typeof window === 'undefined' || smoothStreaming === true)
89
+ const hasNodes = $derived(Array.isArray(nodes))
90
+ const parentSmoothStreaming = getContext<SmoothStreamingContextValue | undefined>(
91
+ SMOOTH_STREAMING_CONTEXT,
92
+ )
93
+ const smoothStreamingEligible = $derived.by(() => {
94
+ if (smoothStreaming === false)
95
+ return false
96
+ if (hasNodes)
97
+ return false
98
+ // When the parent renderer is already pacing content, avoid double-pacing
99
+ // in nested renderers (e.g. thinking blocks, custom tag content).
100
+ // Only applies in 'auto' mode — smoothStreaming === true explicitly opts in.
101
+ if (smoothStreaming !== true && parentSmoothStreaming?.())
102
+ return false
103
+ if (smoothStreaming === true)
104
+ return true
105
+ return typewriter === true || (maxLiveNodes ?? 0) <= 0
106
+ })
107
+ const smoothStreamingEnabled = $derived(hasMountedForSmoothStreaming && smoothStreamingEligible)
108
+ setContext(SMOOTH_STREAMING_CONTEXT, () => smoothStreamingEnabled)
109
+
110
+ // Baseline sync: in auto mode with initial static content, ensure the smooth
111
+ // stream source/visible is already synced before smooth streaming activates.
112
+ // This prevents a blank flash when the mount gate opens and renderContent
113
+ // switches from raw content to smoothStream.visible (which would be empty).
114
+ if (
115
+ smoothStreaming !== true
116
+ && !Array.isArray(nodes)
117
+ && content
118
+ ) {
119
+ smoothStream.reset(content)
120
+ }
121
+
122
+ const renderContent = $derived(smoothStreamingEnabled ? smoothStream.visible : (content ?? ''))
123
+ const rawContent = $derived(content ?? '')
124
+ const smoothSourceSynced = $derived(hasNodes || smoothStream.source === rawContent)
125
+ const requestedFinal = $derived.by(() => {
126
+ const base = parseOptions ?? {}
127
+ return final ?? (base as any).final
128
+ })
129
+ const effectiveFinal = $derived.by(() => {
130
+ if (smoothStreamingEnabled && requestedFinal != null)
131
+ return Boolean(requestedFinal && smoothSourceSynced && smoothStream.caughtUp)
132
+ return requestedFinal
133
+ })
134
+
135
+ onMount(() => {
136
+ hasMountedForSmoothStreaming = true
137
+ })
138
+
139
+ $effect(() => {
140
+ const nextContent = content ?? ''
141
+ if (hasNodes) {
142
+ smoothStream.reset('')
143
+ return
144
+ }
145
+ if (!smoothStreamingEnabled) {
146
+ smoothStream.reset(nextContent)
147
+ if (requestedFinal)
148
+ smoothStream.finish({ flush: true })
149
+ return
150
+ }
151
+ const source = smoothStream.source
152
+ if (!nextContent) {
153
+ smoothStream.reset('')
154
+ }
155
+ else if (nextContent === source) {
156
+ // no-op
157
+ }
158
+ else if (nextContent.startsWith(source)) {
159
+ smoothStream.enqueue(nextContent.slice(source.length))
160
+ }
161
+ else {
162
+ smoothStream.reset(nextContent)
163
+ }
164
+ if (requestedFinal)
165
+ smoothStream.finish()
166
+ })
167
+
81
168
  let rendererProps = $derived({
82
- content,
169
+ content: renderContent,
83
170
  nodes,
84
- final,
171
+ final: effectiveFinal,
85
172
  parseOptions,
86
173
  customMarkdownIt,
87
174
  debugPerformance,
@@ -106,6 +193,7 @@
106
193
  customId,
107
194
  indexKey,
108
195
  typewriter,
196
+ fade,
109
197
  batchRendering,
110
198
  initialRenderBatchSize,
111
199
  renderBatchSize,
@@ -116,12 +204,14 @@
116
204
  maxLiveNodes,
117
205
  liveNodeBuffer,
118
206
  allowHtml,
207
+ smoothStreaming,
208
+ smoothStreamingOptions,
119
209
  } satisfies NodeRendererProps)
120
210
 
121
211
  $effect.pre(() => {
122
- if (previousContent !== content || previousNodes !== nodes) {
212
+ if (previousContent !== renderContent || previousNodes !== nodes) {
123
213
  streamRenderVersion += 1
124
- previousContent = content
214
+ previousContent = renderContent
125
215
  previousNodes = nodes
126
216
  }
127
217
  })
@@ -133,7 +223,7 @@
133
223
  console.info('[markstream-svelte][perf] parse(sync)', {
134
224
  ms: Math.round(performance.now() - start),
135
225
  nodes: nextParsedNodes.length,
136
- contentLength: content?.length ?? 0,
226
+ contentLength: renderContent?.length ?? 0,
137
227
  })
138
228
  }
139
229
  return nextParsedNodes
@@ -167,7 +257,7 @@
167
257
  void renderBatchDelay
168
258
  void renderBatchBudgetMs
169
259
  void renderBatchIdleTimeoutMs
170
- void final
260
+ void effectiveFinal
171
261
  void renderedNodeCount
172
262
  syncRenderedNodeWindow()
173
263
  })
@@ -175,7 +265,7 @@
175
265
 
176
266
  $effect(() => {
177
267
  void rootEl
178
- void final
268
+ void effectiveFinal
179
269
  void parsedNodes
180
270
  void renderedNodes
181
271
  void isDark
@@ -203,6 +293,7 @@
203
293
  enhancementHandle?.dispose()
204
294
  enhancementHandle = null
205
295
  disposeRenderedHtmlEnhancements(rootEl)
296
+ clearTypewriterCursorTimeout()
206
297
  })
207
298
 
208
299
  function toPositiveInteger(value: unknown, fallback: number) {
@@ -213,7 +304,7 @@
213
304
  function syncRenderedNodeWindow() {
214
305
  const total = parsedNodes.length
215
306
  const initialCount = Math.min(total, toPositiveInteger(initialRenderBatchSize, 40))
216
- const shouldBatch = final !== false && batchRendering !== false && total > initialCount
307
+ const shouldBatch = effectiveFinal !== false && batchRendering !== false && total > initialCount
217
308
 
218
309
  if (!shouldBatch) {
219
310
  cancelRenderBatch()
@@ -296,7 +387,7 @@
296
387
  async function scheduleEnhancement() {
297
388
  if (!rootEl || typeof window === 'undefined')
298
389
  return
299
- const enhancementFinal = typeof final === 'boolean' ? final : !hasLoadingNodes(parsedNodes)
390
+ const enhancementFinal = typeof effectiveFinal === 'boolean' ? effectiveFinal : !hasLoadingNodes(parsedNodes)
300
391
  if (!enhancementFinal) {
301
392
  enhancementToken += 1
302
393
  enhancementHandle?.dispose()
@@ -335,6 +426,156 @@
335
426
  return false
336
427
  }
337
428
 
429
+ // ── Typewriter cursor ──
430
+ let typewriterCursorEl: HTMLSpanElement | null = $state(null)
431
+ let showTypewriterCursor = $state(false)
432
+ let typewriterCursorTimeout: ReturnType<typeof setTimeout> | undefined
433
+ let lastTypewriterContentLength = 0
434
+ const TYPEWRITER_CURSOR_EXCLUDED_NODE_TYPES = new Set(['code_block', 'admonition', 'table', 'math_block', 'html_block', 'image'])
435
+
436
+ function shouldSkipTypewriterCursorForNode(node: unknown) {
437
+ if (!node || typeof node !== 'object')
438
+ return false
439
+ const type = (node as Record<string, unknown>).type
440
+ return typeof type === 'string' && TYPEWRITER_CURSOR_EXCLUDED_NODE_TYPES.has(type)
441
+ }
442
+
443
+ function shouldShowTypewriterCursorForCurrentNodes() {
444
+ const lastNode = parsedNodes[parsedNodes.length - 1]
445
+ return !shouldSkipTypewriterCursorForNode(lastNode)
446
+ }
447
+
448
+ function getNodeTextLength(node: unknown): number {
449
+ if (!node || typeof node !== 'object')
450
+ return 0
451
+ const record = node as Record<string, unknown>
452
+ const direct = record.raw ?? record.content ?? record.code
453
+ if (typeof direct === 'string')
454
+ return direct.length
455
+ const children = record.children
456
+ if (Array.isArray(children))
457
+ return children.reduce((total: number, child: unknown) => total + getNodeTextLength(child), 0)
458
+ const items = record.items
459
+ if (Array.isArray(items))
460
+ return items.reduce((total: number, item: unknown) => total + getNodeTextLength(item), 0)
461
+ return 0
462
+ }
463
+
464
+ function getTypewriterContentLength() {
465
+ if (nodes?.length)
466
+ return nodes.reduce((total: number, node: unknown) => total + getNodeTextLength(node), 0)
467
+ // Use raw content length, not renderContent (which may be the paced-out
468
+ // visible portion when smooth streaming is active).
469
+ return (content ?? '').length
470
+ }
471
+
472
+ function clearTypewriterCursorTimeout() {
473
+ if (!typewriterCursorTimeout)
474
+ return
475
+ clearTimeout(typewriterCursorTimeout)
476
+ typewriterCursorTimeout = undefined
477
+ }
478
+
479
+ function getLastTextNode(root: HTMLElement) {
480
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
481
+ acceptNode(node) {
482
+ const text = node.textContent ?? ''
483
+ if (!text.trim())
484
+ return NodeFilter.FILTER_REJECT
485
+ const parent = node.parentElement
486
+ if (!parent)
487
+ return NodeFilter.FILTER_REJECT
488
+ if (parent.closest('.typewriter-cursor, .height-estimation-probes, [data-node-type="code_block"], [data-node-type="admonition"], [data-node-type="table"], [data-node-type="math_block"], [data-node-type="html_block"], [data-node-type="image"], script, style'))
489
+ return NodeFilter.FILTER_REJECT
490
+ return NodeFilter.FILTER_ACCEPT
491
+ },
492
+ })
493
+ let last: Text | null = null
494
+ let current = walker.nextNode()
495
+ while (current) {
496
+ last = current as Text
497
+ current = walker.nextNode()
498
+ }
499
+ return last
500
+ }
501
+
502
+ function updateTypewriterCursorPosition() {
503
+ if (typeof window === 'undefined' || !showTypewriterCursor || !rootEl || !typewriterCursorEl)
504
+ return
505
+ const root = rootEl
506
+ const cursor = typewriterCursorEl
507
+ const lastText = getLastTextNode(root)
508
+ const rootRect = root.getBoundingClientRect()
509
+ let left = 0
510
+ let top = 0
511
+ let height = 20
512
+
513
+ if (lastText?.textContent) {
514
+ const range = document.createRange()
515
+ const end = lastText.textContent.length
516
+ range.setStart(lastText, Math.max(0, end - 1))
517
+ range.setEnd(lastText, end)
518
+ const rects = typeof range.getClientRects === 'function'
519
+ ? range.getClientRects()
520
+ : undefined
521
+ const rect = rects?.[rects.length - 1] ?? lastText.parentElement?.getBoundingClientRect()
522
+ if (rect) {
523
+ left = rect.right - rootRect.left + root.scrollLeft
524
+ top = rect.top - rootRect.top + root.scrollTop
525
+ height = rect.height || height
526
+ }
527
+ range.detach()
528
+ }
529
+
530
+ cursor.style.transform = `translate(${Math.max(0, left)}px, ${Math.max(0, top)}px)`
531
+ cursor.style.height = `${height}px`
532
+ }
533
+
534
+ $effect(() => {
535
+ void renderContent
536
+ void nodes
537
+ void typewriter
538
+ void parsedNodes.length
539
+ void effectiveFinal
540
+ if (typeof window === 'undefined' || hasNodes)
541
+ return
542
+
543
+ // When the stream is final (and effective — smooth streaming has caught up),
544
+ // hide the cursor immediately.
545
+ if (effectiveFinal) {
546
+ showTypewriterCursor = false
547
+ clearTypewriterCursorTimeout()
548
+ return
549
+ }
550
+
551
+ const nextLength = getTypewriterContentLength()
552
+ const cursorAllowed = shouldShowTypewriterCursorForCurrentNodes()
553
+ if (typewriter === false || !cursorAllowed || nextLength <= lastTypewriterContentLength) {
554
+ if (typewriter === false || !cursorAllowed)
555
+ showTypewriterCursor = false
556
+ lastTypewriterContentLength = nextLength
557
+ return
558
+ }
559
+
560
+ lastTypewriterContentLength = nextLength
561
+ showTypewriterCursor = true
562
+ clearTypewriterCursorTimeout()
563
+ tick().then(() => {
564
+ updateTypewriterCursorPosition()
565
+ })
566
+ typewriterCursorTimeout = setTimeout(() => {
567
+ showTypewriterCursor = false
568
+ }, 3000)
569
+ })
570
+
571
+ $effect(() => {
572
+ if (!showTypewriterCursor)
573
+ return
574
+ tick().then(() => {
575
+ updateTypewriterCursorPosition()
576
+ })
577
+ })
578
+
338
579
  function handleMouseover(event: MouseEvent) {
339
580
  const target = event.target as HTMLElement | null
340
581
  if (target?.closest('[data-node-index]'))
@@ -362,9 +603,10 @@
362
603
  >
363
604
  {#each renderedNodes as node, index ((indexKey != null ? String(indexKey) : 'markdown-renderer') + '-' + index)}
364
605
  <div class="node-slot" data-node-index={index} data-node-type={(node as any)?.type}>
365
- <div class:typewriter-node={typewriter !== false && String((node as any)?.type || '') !== 'code_block'} class="node-content" data-node-index={index}>
606
+ <div class:fade-node={fade !== false && String((node as any)?.type || '') !== 'code_block'} class:typewriter-node={fade !== false && String((node as any)?.type || '') !== 'code_block'} class="node-content" data-node-index={index}>
366
607
  <NodeOutlet node={node} context={renderContext} indexKey={(indexKey != null ? String(indexKey) : 'markdown-renderer') + '-' + index} />
367
608
  </div>
368
609
  </div>
369
610
  {/each}
611
+ {#if showTypewriterCursor}<span bind:this={typewriterCursorEl} class="typewriter-cursor" aria-hidden="true"></span>{/if}
370
612
  </div>
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { SvelteRenderableNode, SvelteRenderContext } from './shared/node-helpers'
3
+ import { resolveStreamingTextState } from 'markstream-core'
3
4
  import { getString } from './shared/node-helpers'
4
5
 
5
6
  interface Props {
@@ -23,30 +24,27 @@
23
24
  const content = $derived(getString((node as any)?.content ?? (node as any)?.raw))
24
25
  const centered = $derived(Boolean((node as any)?.center))
25
26
  const streamKey = $derived(String(context?.customId ?? 'global') + ':' + String(context?.streamRenderVersion ?? 0) + ':' + String(indexKey ?? 'node'))
27
+ const fadeEnabled = $derived(context?.fade !== false)
26
28
 
27
29
  const streamInfo = $derived.by(() => {
28
30
  const state = context?.textStreamState
29
31
  const previous = streamKey === previousKey ? previousContent : (state?.get(streamKey) ?? '')
30
-
31
- let stableContent = ''
32
- let deltaContent = ''
33
32
 
34
- if (previous && content.startsWith(previous) && content.length > previous.length) {
35
- stableContent = previous
36
- deltaContent = content.slice(previous.length)
33
+ const result = resolveStreamingTextState({
34
+ nextContent: content,
35
+ previousContent: previous,
36
+ typewriterEnabled: fadeEnabled,
37
+ })
38
+
39
+ if (result.appended)
37
40
  deltaClass = deltaClass.endsWith('--a') ? 'markstream-svelte-text__stream-delta--b' : 'markstream-svelte-text__stream-delta--a'
38
- }
39
- else {
40
- stableContent = content
41
- deltaContent = ''
42
- }
43
-
41
+
44
42
  previousKey = streamKey
45
43
  previousContent = content
46
44
  state?.set(streamKey, content)
47
45
 
48
- return { stableContent, deltaContent, deltaClass }
46
+ return { stableContent: result.settledContent, deltaContent: result.streamedDelta, deltaClass }
49
47
  })
50
48
  </script>
51
49
 
52
- <span data-typewriter={typewriter !== false ? '1' : undefined} class:markstream-svelte-text--centered={centered} class="markstream-svelte-text-node text-node">{streamInfo.stableContent}{#if streamInfo.deltaContent}<span class="markstream-svelte-text__stream-delta text-node-stream-delta {streamInfo.deltaClass}">{streamInfo.deltaContent}</span>{/if}</span>
50
+ <span data-typewriter={context?.typewriter === true ? '1' : undefined} class:markstream-svelte-text--centered={centered} class="markstream-svelte-text-node text-node">{streamInfo.stableContent}{#if streamInfo.deltaContent}<span class="markstream-svelte-text__stream-delta text-node-stream-delta {streamInfo.deltaClass}">{streamInfo.deltaContent}</span>{/if}</span>
@@ -1,3 +1,4 @@
1
+ import type { SmoothMarkdownStreamOptions } from 'markstream-core';
1
2
  import type { BaseNode, HtmlPolicy, MarkdownIt, ParsedNode, ParseOptions } from 'stream-markdown-parser';
2
3
  import type { CustomComponentMap } from '../../customComponents';
3
4
  import type { CodeBlockMonacoOptions, CodeBlockMonacoTheme } from '../../types/monaco';
@@ -99,6 +100,7 @@ export interface NodeRendererProps {
99
100
  customId?: string;
100
101
  indexKey?: number | string;
101
102
  typewriter?: boolean;
103
+ fade?: boolean;
102
104
  batchRendering?: boolean;
103
105
  initialRenderBatchSize?: number;
104
106
  renderBatchSize?: number;
@@ -109,6 +111,8 @@ export interface NodeRendererProps {
109
111
  maxLiveNodes?: number;
110
112
  liveNodeBuffer?: number;
111
113
  allowHtml?: boolean;
114
+ smoothStreaming?: boolean | 'auto';
115
+ smoothStreamingOptions?: SmoothMarkdownStreamOptions;
112
116
  }
113
117
  export interface SvelteRenderContext {
114
118
  customId?: string;
@@ -116,6 +120,7 @@ export interface SvelteRenderContext {
116
120
  indexKey?: string;
117
121
  final?: boolean;
118
122
  typewriter?: boolean;
123
+ fade?: boolean;
119
124
  textStreamState?: Map<string, string>;
120
125
  streamRenderVersion?: number;
121
126
  showTooltips?: boolean;
@@ -29,6 +29,7 @@ export function buildRenderContext(props, events = {}, textStreamState, streamRe
29
29
  indexKey: props.indexKey != null ? String(props.indexKey) : undefined,
30
30
  final: props.final,
31
31
  typewriter: props.typewriter,
32
+ fade: props.fade,
32
33
  textStreamState,
33
34
  streamRenderVersion,
34
35
  showTooltips: props.showTooltips,
@@ -0,0 +1,19 @@
1
+ import type { SmoothMarkdownStreamOptions } from 'markstream-core';
2
+ export type { SmoothMarkdownStreamOptions };
3
+ export interface SmoothMarkdownStreamControllerSvelte {
4
+ source: string;
5
+ visible: string;
6
+ done: boolean;
7
+ caughtUp: boolean;
8
+ final: boolean;
9
+ pendingChars: number;
10
+ enqueue: (chunk: string) => void;
11
+ finish: (options?: {
12
+ flush?: boolean;
13
+ }) => void;
14
+ flush: () => void;
15
+ reset: (initialMarkdown?: string) => void;
16
+ pause: () => void;
17
+ resume: () => void;
18
+ }
19
+ export declare function useSmoothMarkdownStream(options?: SmoothMarkdownStreamOptions): SmoothMarkdownStreamControllerSvelte;
@@ -0,0 +1,40 @@
1
+ import { createSmoothMarkdownStream } from 'markstream-core';
2
+ import { onDestroy } from 'svelte';
3
+ export function useSmoothMarkdownStream(options = {}) {
4
+ let source = $state('');
5
+ let visible = $state('');
6
+ let done = $state(false);
7
+ let pendingChars = $state(0);
8
+ let caughtUp = $state(false);
9
+ let final = $state(false);
10
+ const controller = createSmoothMarkdownStream(options);
11
+ const sync = () => {
12
+ const snapshot = controller.getSnapshot();
13
+ source = snapshot.source;
14
+ visible = snapshot.visible;
15
+ done = snapshot.done;
16
+ pendingChars = snapshot.pendingChars;
17
+ caughtUp = snapshot.caughtUp;
18
+ final = snapshot.final;
19
+ };
20
+ const unsubscribe = controller.subscribe(sync);
21
+ sync();
22
+ onDestroy(() => {
23
+ unsubscribe();
24
+ controller.destroy();
25
+ });
26
+ return {
27
+ get source() { return source; },
28
+ get visible() { return visible; },
29
+ get done() { return done; },
30
+ get caughtUp() { return caughtUp; },
31
+ get final() { return final; },
32
+ get pendingChars() { return pendingChars; },
33
+ enqueue: chunk => controller.enqueue(chunk),
34
+ finish: opts => controller.finish(opts),
35
+ flush: () => controller.flush(),
36
+ reset: initialMarkdown => controller.reset(initialMarkdown),
37
+ pause: () => controller.pause(),
38
+ resume: () => controller.resume(),
39
+ };
40
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Context key and types for Svelte smooth streaming parent suppression.
3
+ *
4
+ * When a parent NodeRenderer is already pacing content via smooth streaming,
5
+ * nested renderers (e.g. thinking blocks, custom tag content) should suppress
6
+ * their own smooth streaming to avoid double-pacing.
7
+ *
8
+ * Usage:
9
+ * const parentSmoothStreaming = getContext<SmoothStreamingContextValue | undefined>(
10
+ * SMOOTH_STREAMING_CONTEXT,
11
+ * )
12
+ * setContext(SMOOTH_STREAMING_CONTEXT, () => smoothStreamingEnabled)
13
+ */
14
+ export declare const SMOOTH_STREAMING_CONTEXT = "markstreamSmoothStreaming";
15
+ export type SmoothStreamingContextValue = () => boolean;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Context key and types for Svelte smooth streaming parent suppression.
3
+ *
4
+ * When a parent NodeRenderer is already pacing content via smooth streaming,
5
+ * nested renderers (e.g. thinking blocks, custom tag content) should suppress
6
+ * their own smooth streaming to avoid double-pacing.
7
+ *
8
+ * Usage:
9
+ * const parentSmoothStreaming = getContext<SmoothStreamingContextValue | undefined>(
10
+ * SMOOTH_STREAMING_CONTEXT,
11
+ * )
12
+ * setContext(SMOOTH_STREAMING_CONTEXT, () => smoothStreamingEnabled)
13
+ */
14
+ export const SMOOTH_STREAMING_CONTEXT = 'markstreamSmoothStreaming';
package/dist/index.css CHANGED
@@ -130,9 +130,10 @@ markstream-svelte {
130
130
  width: 100%;
131
131
  }
132
132
 
133
- .markstream-svelte .typewriter-node {
133
+ .markstream-svelte .typewriter-node,
134
+ .markstream-svelte .fade-node {
134
135
  opacity: 0;
135
- animation: typewriter-fade var(--typewriter-fade-duration, 900ms) var(--typewriter-fade-ease, ease-out) both;
136
+ animation: typewriter-fade var(--fade-duration, var(--typewriter-fade-duration, 280ms)) var(--fade-ease, var(--typewriter-fade-ease, cubic-bezier(0.33, 0, 0.67, 1))) both;
136
137
  will-change: opacity;
137
138
  }
138
139
 
@@ -151,8 +152,8 @@ markstream-svelte {
151
152
  }
152
153
 
153
154
  .markstream-svelte .markstream-svelte-text__stream-delta {
154
- animation-duration: var(--stream-update-fade-duration, var(--typewriter-fade-duration, 900ms));
155
- animation-timing-function: var(--stream-update-fade-ease, var(--typewriter-fade-ease, ease-out));
155
+ animation-duration: var(--stream-update-fade-duration, var(--fade-duration, var(--typewriter-fade-duration, 280ms)));
156
+ animation-timing-function: var(--stream-update-fade-ease, var(--fade-ease, var(--typewriter-fade-ease, cubic-bezier(0.33, 0, 0.67, 1))));
156
157
  animation-fill-mode: both;
157
158
  will-change: opacity;
158
159
  }
@@ -278,6 +279,29 @@ markstream-svelte {
278
279
  to { opacity: 1; }
279
280
  }
280
281
 
282
+ .markstream-svelte .typewriter-cursor {
283
+ position: absolute;
284
+ left: 0;
285
+ top: 0;
286
+ display: inline-block;
287
+ width: 0.55em;
288
+ height: 1em;
289
+ margin-left: 0.08em;
290
+ vertical-align: -0.12em;
291
+ border-right: 2px solid currentColor;
292
+ pointer-events: none;
293
+ animation: typewriter-cursor-blink 1s steps(1, end) infinite;
294
+ }
295
+
296
+ @keyframes typewriter-cursor-blink {
297
+ 0%, 49% {
298
+ opacity: 1;
299
+ }
300
+ 50%, 100% {
301
+ opacity: 0;
302
+ }
303
+ }
304
+
281
305
  @media (prefers-reduced-motion: reduce) {
282
306
  .markstream-svelte .link-loading-indicator {
283
307
  animation: none !important;
@@ -287,6 +311,16 @@ markstream-svelte {
287
311
  .markstream-svelte .markstream-svelte-text__stream-delta {
288
312
  animation: none !important;
289
313
  }
314
+
315
+ .markstream-svelte .typewriter-node,
316
+ .markstream-svelte .fade-node {
317
+ animation: none !important;
318
+ opacity: 1;
319
+ }
320
+
321
+ .markstream-svelte .typewriter-cursor {
322
+ animation: none !important;
323
+ }
290
324
  }
291
325
 
292
326
  .markstream-svelte strong {
package/dist/index.d.ts CHANGED
@@ -49,6 +49,10 @@ export { default as TextNode } from './components/TextNode.svelte';
49
49
  export { default as ThematicBreakNode } from './components/ThematicBreakNode.svelte';
50
50
  export { default as Tooltip } from './components/Tooltip.svelte';
51
51
  export { default as VmrContainerNode } from './components/VmrContainerNode.svelte';
52
+ export type { SmoothMarkdownStreamControllerSvelte, SmoothMarkdownStreamOptions, } from './composables/useSmoothMarkdownStream.svelte';
53
+ export { useSmoothMarkdownStream, } from './composables/useSmoothMarkdownStream.svelte';
54
+ export { SMOOTH_STREAMING_CONTEXT, } from './context/smoothStreaming';
55
+ export type { SmoothStreamingContextValue, } from './context/smoothStreaming';
52
56
  export { clearGlobalCustomComponents, getCustomComponentsRevision, getCustomNodeComponents, removeCustomComponents, setCustomComponents, subscribeCustomComponents, } from './customComponents';
53
57
  export type { CustomComponentMap, MarkstreamSvelteComponent } from './customComponents';
54
58
  export { disposeRenderedHtmlEnhancements, enhanceRenderedHtml, } from './enhanceRenderedHtml';
package/dist/index.js CHANGED
@@ -48,6 +48,8 @@ export { default as TextNode } from './components/TextNode.svelte';
48
48
  export { default as ThematicBreakNode } from './components/ThematicBreakNode.svelte';
49
49
  export { default as Tooltip } from './components/Tooltip.svelte';
50
50
  export { default as VmrContainerNode } from './components/VmrContainerNode.svelte';
51
+ export { useSmoothMarkdownStream, } from './composables/useSmoothMarkdownStream.svelte';
52
+ export { SMOOTH_STREAMING_CONTEXT, } from './context/smoothStreaming';
51
53
  export { clearGlobalCustomComponents, getCustomComponentsRevision, getCustomNodeComponents, removeCustomComponents, setCustomComponents, subscribeCustomComponents, } from './customComponents';
52
54
  export { disposeRenderedHtmlEnhancements, enhanceRenderedHtml, } from './enhanceRenderedHtml';
53
55
  export { setDefaultI18nMap, useSafeI18n } from './i18n/useSafeI18n';
package/dist/index.px.css CHANGED
@@ -130,9 +130,10 @@ markstream-svelte {
130
130
  width: 100%;
131
131
  }
132
132
 
133
- .markstream-svelte .typewriter-node {
133
+ .markstream-svelte .typewriter-node,
134
+ .markstream-svelte .fade-node {
134
135
  opacity: 0;
135
- animation: typewriter-fade var(--typewriter-fade-duration, 900ms) var(--typewriter-fade-ease, ease-out) both;
136
+ animation: typewriter-fade var(--fade-duration, var(--typewriter-fade-duration, 280ms)) var(--fade-ease, var(--typewriter-fade-ease, cubic-bezier(0.33, 0, 0.67, 1))) both;
136
137
  will-change: opacity;
137
138
  }
138
139
 
@@ -151,8 +152,8 @@ markstream-svelte {
151
152
  }
152
153
 
153
154
  .markstream-svelte .markstream-svelte-text__stream-delta {
154
- animation-duration: var(--stream-update-fade-duration, var(--typewriter-fade-duration, 900ms));
155
- animation-timing-function: var(--stream-update-fade-ease, var(--typewriter-fade-ease, ease-out));
155
+ animation-duration: var(--stream-update-fade-duration, var(--fade-duration, var(--typewriter-fade-duration, 280ms)));
156
+ animation-timing-function: var(--stream-update-fade-ease, var(--fade-ease, var(--typewriter-fade-ease, cubic-bezier(0.33, 0, 0.67, 1))));
156
157
  animation-fill-mode: both;
157
158
  will-change: opacity;
158
159
  }
@@ -278,6 +279,29 @@ markstream-svelte {
278
279
  to { opacity: 1; }
279
280
  }
280
281
 
282
+ .markstream-svelte .typewriter-cursor {
283
+ position: absolute;
284
+ left: 0;
285
+ top: 0;
286
+ display: inline-block;
287
+ width: 0.55em;
288
+ height: 1em;
289
+ margin-left: 0.08em;
290
+ vertical-align: -0.12em;
291
+ border-right: 2px solid currentColor;
292
+ pointer-events: none;
293
+ animation: typewriter-cursor-blink 1s steps(1, end) infinite;
294
+ }
295
+
296
+ @keyframes typewriter-cursor-blink {
297
+ 0%, 49% {
298
+ opacity: 1;
299
+ }
300
+ 50%, 100% {
301
+ opacity: 0;
302
+ }
303
+ }
304
+
281
305
  @media (prefers-reduced-motion: reduce) {
282
306
  .markstream-svelte .link-loading-indicator {
283
307
  animation: none !important;
@@ -287,6 +311,16 @@ markstream-svelte {
287
311
  .markstream-svelte .markstream-svelte-text__stream-delta {
288
312
  animation: none !important;
289
313
  }
314
+
315
+ .markstream-svelte .typewriter-node,
316
+ .markstream-svelte .fade-node {
317
+ animation: none !important;
318
+ opacity: 1;
319
+ }
320
+
321
+ .markstream-svelte .typewriter-cursor {
322
+ animation: none !important;
323
+ }
290
324
  }
291
325
 
292
326
  .markstream-svelte strong {
@@ -130,9 +130,10 @@ markstream-svelte {
130
130
  width: 100%;
131
131
  }
132
132
 
133
- .markstream-svelte .typewriter-node {
133
+ .markstream-svelte .typewriter-node,
134
+ .markstream-svelte .fade-node {
134
135
  opacity: 0;
135
- animation: typewriter-fade var(--typewriter-fade-duration, 900ms) var(--typewriter-fade-ease, ease-out) both;
136
+ animation: typewriter-fade var(--fade-duration, var(--typewriter-fade-duration, 280ms)) var(--fade-ease, var(--typewriter-fade-ease, cubic-bezier(0.33, 0, 0.67, 1))) both;
136
137
  will-change: opacity;
137
138
  }
138
139
 
@@ -151,8 +152,8 @@ markstream-svelte {
151
152
  }
152
153
 
153
154
  .markstream-svelte .markstream-svelte-text__stream-delta {
154
- animation-duration: var(--stream-update-fade-duration, var(--typewriter-fade-duration, 900ms));
155
- animation-timing-function: var(--stream-update-fade-ease, var(--typewriter-fade-ease, ease-out));
155
+ animation-duration: var(--stream-update-fade-duration, var(--fade-duration, var(--typewriter-fade-duration, 280ms)));
156
+ animation-timing-function: var(--stream-update-fade-ease, var(--fade-ease, var(--typewriter-fade-ease, cubic-bezier(0.33, 0, 0.67, 1))));
156
157
  animation-fill-mode: both;
157
158
  will-change: opacity;
158
159
  }
@@ -278,6 +279,29 @@ markstream-svelte {
278
279
  to { opacity: 1; }
279
280
  }
280
281
 
282
+ .markstream-svelte .typewriter-cursor {
283
+ position: absolute;
284
+ left: 0;
285
+ top: 0;
286
+ display: inline-block;
287
+ width: 0.55em;
288
+ height: 1em;
289
+ margin-left: 0.08em;
290
+ vertical-align: -0.12em;
291
+ border-right: 2px solid currentColor;
292
+ pointer-events: none;
293
+ animation: typewriter-cursor-blink 1s steps(1, end) infinite;
294
+ }
295
+
296
+ @keyframes typewriter-cursor-blink {
297
+ 0%, 49% {
298
+ opacity: 1;
299
+ }
300
+ 50%, 100% {
301
+ opacity: 0;
302
+ }
303
+ }
304
+
281
305
  @media (prefers-reduced-motion: reduce) {
282
306
  .markstream-svelte .link-loading-indicator {
283
307
  animation: none !important;
@@ -287,6 +311,16 @@ markstream-svelte {
287
311
  .markstream-svelte .markstream-svelte-text__stream-delta {
288
312
  animation: none !important;
289
313
  }
314
+
315
+ .markstream-svelte .typewriter-node,
316
+ .markstream-svelte .fade-node {
317
+ animation: none !important;
318
+ opacity: 1;
319
+ }
320
+
321
+ .markstream-svelte .typewriter-cursor {
322
+ animation: none !important;
323
+ }
290
324
  }
291
325
 
292
326
  .markstream-svelte strong {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "markstream-svelte",
3
3
  "type": "module",
4
- "version": "0.0.1-beta.4",
4
+ "version": "0.0.1-beta.5",
5
5
  "description": "Svelte Markdown renderer for Markstream, aligned with markstream-vue and markstream-react.",
6
6
  "author": "Simon He",
7
7
  "license": "MIT",
@@ -71,7 +71,8 @@
71
71
  },
72
72
  "dependencies": {
73
73
  "@floating-ui/dom": "^1.7.6",
74
- "stream-markdown-parser": "0.0.93"
74
+ "markstream-core": "0.0.1",
75
+ "stream-markdown-parser": "0.0.95"
75
76
  },
76
77
  "devDependencies": {
77
78
  "@sveltejs/package": "^2.5.7",
@@ -86,10 +87,11 @@
86
87
  "vite": "^7.3.1"
87
88
  },
88
89
  "scripts": {
89
- "build": "svelte-package -i src -o dist && postcss src/index.css -o dist/index.css && cp dist/index.css dist/index.tailwind.css && node ../../scripts/generate-px-css.mjs",
90
+ "build": "pnpm --dir ../markstream-core build && svelte-package -i src -o dist && postcss src/index.css -o dist/index.css && cp dist/index.css dist/index.tailwind.css && node ../../scripts/generate-px-css.mjs",
90
91
  "dev": "vite dev",
91
92
  "preview": "vite preview",
92
93
  "typecheck": "svelte-check --tsconfig ./tsconfig.json",
93
- "release": "bumpp --commit --no-tag --no-push && pnpm publish --access public && node ../../scripts/tag-package.mjs --package-json package.json --push"
94
+ "check:core-published": "node ../../scripts/check-core-published.mjs --package-json package.json --core-package-json ../markstream-core/package.json",
95
+ "release": "pnpm run check:core-published && bumpp --commit --no-tag --no-push && pnpm publish --access public && node ../../scripts/tag-package.mjs --package-json package.json --push"
94
96
  }
95
97
  }