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

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,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { SvelteRenderableNode } from './shared/node-helpers'
3
+ import { sanitizeImageSrc } from 'stream-markdown-parser'
3
4
  import { useSafeI18n } from '../i18n/useSafeI18n'
4
5
  import { getString } from './shared/node-helpers'
5
6
 
@@ -21,38 +22,58 @@
21
22
 
22
23
  const { t } = useSafeI18n()
23
24
 
24
- let src = $derived(getString((node as any)?.src))
25
+ type ImageStage = 'primary' | 'fallback' | 'failed'
26
+
27
+ function resolveImageState(primarySrc: string, fallbackSrc: string, loading: boolean): { src: string; stage: ImageStage } {
28
+ if (loading || primarySrc)
29
+ return { src: primarySrc, stage: 'primary' }
30
+ if (fallbackSrc)
31
+ return { src: fallbackSrc, stage: 'fallback' }
32
+ return { src: '', stage: 'failed' }
33
+ }
34
+
35
+ let safeNodeSrc = $derived(sanitizeImageSrc((node as any)?.src))
36
+ let safeFallbackSrc = $derived(sanitizeImageSrc(fallbackSrc))
25
37
  let alt = $derived(getString((node as any)?.alt))
26
38
  let title = $derived(getString((node as any)?.title))
27
39
  let raw = $derived(getString((node as any)?.raw))
28
40
  let isLoading = $derived(Boolean((node as any)?.loading))
29
41
  let useEagerImagePath = $derived(!lazy)
30
42
 
31
- let initialSrc = untrack(() => getString((node as any)?.src))
32
- let previousSrc = $state(initialSrc)
33
- let currentSrc = $state(initialSrc)
43
+ let initialSafeNodeSrc = untrack(() => sanitizeImageSrc((node as any)?.src))
44
+ let initialSafeFallbackSrc = untrack(() => sanitizeImageSrc(fallbackSrc))
45
+ let initialIsLoading = untrack(() => Boolean((node as any)?.loading))
46
+ let initialImageState = resolveImageState(initialSafeNodeSrc, initialSafeFallbackSrc, initialIsLoading)
47
+ let previousSafeNodeSrc = $state(initialSafeNodeSrc)
48
+ let previousSafeFallbackSrc = $state(initialSafeFallbackSrc)
49
+ let previousIsLoading = $state(initialIsLoading)
50
+ let currentSrc = $state(initialImageState.src)
51
+ let imageStage = $state<ImageStage>(initialImageState.stage)
34
52
  let imageLoaded = $state(false)
35
- let hasError = $state(false)
36
- let fallbackTried = $state(false)
53
+ let hasError = $state(initialImageState.stage === 'failed')
37
54
 
38
55
  $effect.pre(() => {
39
- if (src !== previousSrc) {
40
- previousSrc = src
41
- currentSrc = src
56
+ if (safeNodeSrc !== previousSafeNodeSrc || safeFallbackSrc !== previousSafeFallbackSrc || isLoading !== previousIsLoading) {
57
+ previousSafeNodeSrc = safeNodeSrc
58
+ previousSafeFallbackSrc = safeFallbackSrc
59
+ previousIsLoading = isLoading
60
+ const next = resolveImageState(safeNodeSrc, safeFallbackSrc, isLoading)
61
+ currentSrc = next.src
62
+ imageStage = next.stage
42
63
  imageLoaded = false
43
- hasError = false
44
- fallbackTried = false
64
+ hasError = next.stage === 'failed'
45
65
  }
46
66
  })
47
67
 
48
68
  function handleImageError() {
49
- if (fallbackSrc && !fallbackTried) {
50
- fallbackTried = true
51
- currentSrc = fallbackSrc
69
+ if (imageStage === 'primary' && safeFallbackSrc && safeFallbackSrc !== currentSrc) {
70
+ currentSrc = safeFallbackSrc
71
+ imageStage = 'fallback'
52
72
  imageLoaded = false
53
73
  hasError = false
54
74
  return
55
75
  }
76
+ imageStage = 'failed'
56
77
  hasError = true
57
78
  imageLoaded = false
58
79
  }
@@ -69,7 +90,7 @@
69
90
  </script>
70
91
 
71
92
  <span class="image-node-container">
72
- {#if !isLoading && !hasError}
93
+ {#if !isLoading && !hasError && currentSrc}
73
94
  <img
74
95
  class="image-node__img"
75
96
  class:is-loaded={useEagerImagePath || imageLoaded}
@@ -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>;
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { SvelteRenderableNode, SvelteRenderContext } from './shared/node-helpers'
3
+ import { sanitizeHtmlAttrs, shouldOpenLinkInNewTab } from 'stream-markdown-parser'
3
4
  import { hideTooltip, showTooltipForAnchor } from '../tooltip/singletonTooltip'
4
5
  import RenderChildren from './RenderChildren.svelte'
5
6
  import { getNodeList, getString } from './shared/node-helpers'
@@ -13,11 +14,12 @@
13
14
 
14
15
  let { node, context, indexKey, showTooltip }: Props = $props();
15
16
 
16
- let href = $derived(getString((node as any)?.href));
17
+ let href = $derived(sanitizeHtmlAttrs({ href: getString((node as any)?.href) }, 'safe', 'a').href ?? '');
17
18
  let title = $derived(getString((node as any)?.title || href));
18
19
  let children = $derived(getNodeList((node as any)?.children));
19
20
  let tooltipEnabled = $derived(showTooltip ?? context?.showTooltips ?? true);
20
21
  let isHashLink = $derived(href.startsWith('#') && href.length > 1);
22
+ let openInNewTab = $derived(shouldOpenLinkInNewTab(href));
21
23
 
22
24
  function showLinkTooltip(event: MouseEvent | FocusEvent) {
23
25
  if (!tooltipEnabled || !title)
@@ -44,4 +46,4 @@
44
46
  }
45
47
  }
46
48
  </script>
47
- <a class:link-loading={Boolean((node as any)?.loading)} class="link-node" href={href || undefined} title={tooltipEnabled ? undefined : title} onblur={() => hideTooltip()} onclick={scrollToHashTarget} onfocus={showLinkTooltip} onmouseleave={() => hideTooltip()} onmouseenter={showLinkTooltip} target={isHashLink ? undefined : '_blank'} rel={isHashLink ? undefined : 'noreferrer noopener'}><span class="link-text-wrapper"><span class="link-text">{#if children.length}<RenderChildren nodes={children} context={context} prefix={String(indexKey ?? 'link') + '-link'} />{:else}{getString((node as any)?.text || href)}{/if}</span>{#if (node as any)?.loading}<span class="link-loading-indicator"></span>{/if}</span></a>
49
+ <a class:link-loading={Boolean((node as any)?.loading)} class="link-node" href={href || undefined} title={tooltipEnabled ? undefined : title} onblur={() => hideTooltip()} onclick={scrollToHashTarget} onfocus={showLinkTooltip} onmouseleave={() => hideTooltip()} onmouseenter={showLinkTooltip} target={openInNewTab ? '_blank' : undefined} rel={openInNewTab ? 'noreferrer noopener' : undefined}><span class="link-text-wrapper"><span class="link-text">{#if children.length}<RenderChildren nodes={children} context={context} prefix={String(indexKey ?? 'link') + '-link'} />{:else}{getString((node as any)?.text || href)}{/if}</span>{#if (node as any)?.loading}<span class="link-loading-indicator"></span>{/if}</span></a>
@@ -1,9 +1,9 @@
1
1
  <script lang="ts">
2
2
  import type { SvelteRenderableNode, SvelteRenderContext } from './shared/node-helpers'
3
- import { onMount, untrack } from 'svelte'
3
+ import { onMount, tick, untrack } from 'svelte'
4
+ import { toSafeMermaidSvgMarkup } from 'stream-markdown-parser'
4
5
  import { useSafeI18n } from '../i18n/useSafeI18n'
5
6
  import { getMermaid } from '../optional/mermaid'
6
- import { toSafeSvgMarkup } from '../sanitizeSvg'
7
7
  import { hideTooltip, showTooltipForAnchor, type TooltipPlacement } from '../tooltip/singletonTooltip'
8
8
  import { getLanguageIcon } from '../utils/languageIcon'
9
9
  import { canParseOffthread, findPrefixOffthread } from '../workers/mermaidWorkerClient'
@@ -33,6 +33,7 @@
33
33
  showCollapseButton?: boolean
34
34
  showZoomControls?: boolean
35
35
  isStrict?: boolean
36
+ enableMermaidInteractions?: boolean
36
37
  }
37
38
 
38
39
  let {
@@ -55,6 +56,7 @@
55
56
  showCollapseButton = true,
56
57
  showZoomControls = true,
57
58
  isStrict = true,
59
+ enableMermaidInteractions = false,
58
60
  }: Props = $props()
59
61
 
60
62
  const { t } = useSafeI18n()
@@ -75,8 +77,11 @@
75
77
  let showSource = $state(false)
76
78
  let modalOpen = $state(false)
77
79
  let zoom = $state(1)
80
+ let previewHost: HTMLElement | null = $state(null)
81
+ let modalHost: HTMLElement | null = $state(null)
78
82
  let renderTimer: ReturnType<typeof setTimeout> | null = $state(null)
79
83
  let copyTimer: ReturnType<typeof setTimeout> | null = $state(null)
84
+ let lastMermaidBindFunctions: ((element: Element) => unknown) | null = null
80
85
 
81
86
  let source = $derived(normalizeMermaidSource(getString((node as any)?.code)))
82
87
  let nodeLoading = $derived(typeof (node as any)?.loading === 'boolean' ? Boolean((node as any)?.loading) : true)
@@ -121,6 +126,11 @@
121
126
  }
122
127
  })
123
128
 
129
+ $effect(() => {
130
+ if (mounted && modalOpen && svgMarkup && modalHost && enableMermaidInteractions)
131
+ untrack(() => void tick().then(() => bindCurrentMermaidInteractions(modalHost)))
132
+ })
133
+
124
134
  function clearRenderTimer() {
125
135
  if (!renderTimer)
126
136
  return
@@ -239,10 +249,11 @@
239
249
  return
240
250
 
241
251
  const rawSvg = typeof rendered === 'string' ? rendered : rendered?.svg
242
- const safeSvg = isStrict ? toSafeSvgMarkup(rawSvg) : rawSvg
252
+ const safeSvg = toSafeMermaidSvgMarkup(rawSvg)
243
253
  if (!safeSvg)
244
254
  throw new Error('Mermaid rendered empty SVG.')
245
255
  svgMarkup = safeSvg
256
+ lastMermaidBindFunctions = typeof rendered === 'string' ? null : rendered?.bindFunctions ?? null
246
257
  renderError = ''
247
258
  lastProgressiveMissSignature = ''
248
259
  if (fullRender) {
@@ -250,8 +261,14 @@
250
261
  lastRenderedCode = normalized
251
262
  svgCache[theme] = safeSvg
252
263
  }
253
- if (typeof rendered !== 'string')
254
- rendered?.bindFunctions?.(document.createElement('div'))
264
+ if (enableMermaidInteractions && typeof rendered !== 'string') {
265
+ await tick()
266
+ if (mounted && token === renderToken) {
267
+ bindCurrentMermaidInteractions(previewHost)
268
+ if (modalOpen)
269
+ bindCurrentMermaidInteractions(modalHost)
270
+ }
271
+ }
255
272
  }
256
273
  catch (error) {
257
274
  if (token === renderToken) {
@@ -385,7 +402,7 @@
385
402
  }
386
403
 
387
404
  function getRenderSignature() {
388
- return `${source}\n${theme}\n${isStrict}\n${final}\n${progressivePreview}`
405
+ return `${source}\n${theme}\n${isStrict}\n${enableMermaidInteractions}\n${final}\n${progressivePreview}`
389
406
  }
390
407
 
391
408
  function withTimeout<T>(run: () => Promise<T>, timeoutMs: number) {
@@ -403,6 +420,15 @@
403
420
  })
404
421
  }
405
422
 
423
+ function bindCurrentMermaidInteractions(element: Element | null | undefined) {
424
+ if (!enableMermaidInteractions || !element?.querySelector('svg'))
425
+ return
426
+ try {
427
+ lastMermaidBindFunctions?.(element)
428
+ }
429
+ catch {}
430
+ }
431
+
406
432
  async function copy() {
407
433
  await copyTextToClipboard(source)
408
434
  context?.events?.onCopy?.(source)
@@ -543,7 +569,7 @@
543
569
  <button type="button" class="mermaid-btn mermaid-action-btn mermaid-zoom-reset" aria-label={t('common.resetZoom')} onblur={() => hideTooltip()} onclick={() => (zoom = 1)} onfocus={(event) => showButtonTooltip(event, t('common.resetZoom') || 'Reset zoom')} onmouseleave={() => hideTooltip()} onmouseenter={(event) => showButtonTooltip(event, t('common.resetZoom') || 'Reset zoom')}>{Math.round(zoom * 100)}%</button>
544
570
  </div>
545
571
  {/if}
546
- <div class="mermaid-preview markstream-svelte-mermaid" style={previewStyle}>
572
+ <div bind:this={previewHost} class="mermaid-preview markstream-svelte-mermaid" style={previewStyle}>
547
573
  {#if svgMarkup}
548
574
  {@html svgMarkup}
549
575
  {:else if renderError}
@@ -576,7 +602,7 @@
576
602
  </button>
577
603
  </div>
578
604
  <div class="mermaid-modal-body">
579
- <div class="mermaid-modal-content markstream-svelte-mermaid" style={`transform: scale(${zoom});`}>{@html svgMarkup}</div>
605
+ <div bind:this={modalHost} class="mermaid-modal-content markstream-svelte-mermaid" style={`transform: scale(${zoom});`}>{@html svgMarkup}</div>
580
606
  </div>
581
607
  </div>
582
608
  </div>
@@ -19,6 +19,7 @@ type Props = {
19
19
  showCollapseButton?: boolean;
20
20
  showZoomControls?: boolean;
21
21
  isStrict?: boolean;
22
+ enableMermaidInteractions?: boolean;
22
23
  };
23
24
  declare const MermaidBlockNode: import("svelte").Component<Props, {}, "">;
24
25
  type MermaidBlockNode = ReturnType<typeof MermaidBlockNode>;
@@ -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,80 @@
78
84
  let renderBatchToken = 0
79
85
  const textStreamState = new Map<string, string>()
80
86
 
87
+ let hasMounted = $state(false)
88
+ const smoothStream = useSmoothMarkdownStream(() => smoothStreamingOptions)
89
+ const hasMountedForSmoothStreaming = $derived(smoothStreaming === true || hasMounted)
90
+ const hasNodes = $derived(Array.isArray(nodes))
91
+ const parentSmoothStreaming = getContext<SmoothStreamingContextValue | undefined>(
92
+ SMOOTH_STREAMING_CONTEXT,
93
+ )
94
+ const smoothStreamingEligible = $derived.by(() => {
95
+ if (smoothStreaming === false)
96
+ return false
97
+ if (hasNodes)
98
+ return false
99
+ // When the parent renderer is already pacing content, avoid double-pacing
100
+ // in nested renderers (e.g. thinking blocks, custom tag content).
101
+ // Only applies in 'auto' mode — smoothStreaming === true explicitly opts in.
102
+ if (smoothStreaming !== true && parentSmoothStreaming?.())
103
+ return false
104
+ if (smoothStreaming === true)
105
+ return true
106
+ return typewriter === true || (maxLiveNodes ?? 0) <= 0
107
+ })
108
+ const smoothStreamingEnabled = $derived(hasMountedForSmoothStreaming && smoothStreamingEligible)
109
+ setContext(SMOOTH_STREAMING_CONTEXT, () => smoothStreamingEnabled)
110
+
111
+ const renderContent = $derived(smoothStreamingEnabled ? smoothStream.visible : (content ?? ''))
112
+ const rawContent = $derived(content ?? '')
113
+ const smoothSourceSynced = $derived(hasNodes || smoothStream.source === rawContent)
114
+ const requestedFinal = $derived.by(() => {
115
+ const base = parseOptions ?? {}
116
+ return final ?? (base as any).final
117
+ })
118
+ const effectiveFinal = $derived.by(() => {
119
+ if (smoothStreamingEnabled && requestedFinal != null)
120
+ return Boolean(requestedFinal && smoothSourceSynced && smoothStream.caughtUp)
121
+ return requestedFinal
122
+ })
123
+
124
+ onMount(() => {
125
+ hasMounted = true
126
+ })
127
+
128
+ $effect(() => {
129
+ const nextContent = content ?? ''
130
+ if (hasNodes) {
131
+ smoothStream.reset('')
132
+ return
133
+ }
134
+ if (!smoothStreamingEnabled) {
135
+ smoothStream.reset(nextContent)
136
+ if (requestedFinal)
137
+ smoothStream.finish({ flush: true })
138
+ return
139
+ }
140
+ const source = smoothStream.source
141
+ if (!nextContent) {
142
+ smoothStream.reset('')
143
+ }
144
+ else if (nextContent === source) {
145
+ // no-op
146
+ }
147
+ else if (nextContent.startsWith(source)) {
148
+ smoothStream.enqueue(nextContent.slice(source.length))
149
+ }
150
+ else {
151
+ smoothStream.reset(nextContent)
152
+ }
153
+ if (requestedFinal)
154
+ smoothStream.finish()
155
+ })
156
+
81
157
  let rendererProps = $derived({
82
- content,
158
+ content: renderContent,
83
159
  nodes,
84
- final,
160
+ final: effectiveFinal,
85
161
  parseOptions,
86
162
  customMarkdownIt,
87
163
  debugPerformance,
@@ -106,6 +182,7 @@
106
182
  customId,
107
183
  indexKey,
108
184
  typewriter,
185
+ fade,
109
186
  batchRendering,
110
187
  initialRenderBatchSize,
111
188
  renderBatchSize,
@@ -116,12 +193,14 @@
116
193
  maxLiveNodes,
117
194
  liveNodeBuffer,
118
195
  allowHtml,
196
+ smoothStreaming,
197
+ smoothStreamingOptions,
119
198
  } satisfies NodeRendererProps)
120
199
 
121
200
  $effect.pre(() => {
122
- if (previousContent !== content || previousNodes !== nodes) {
201
+ if (previousContent !== renderContent || previousNodes !== nodes) {
123
202
  streamRenderVersion += 1
124
- previousContent = content
203
+ previousContent = renderContent
125
204
  previousNodes = nodes
126
205
  }
127
206
  })
@@ -133,7 +212,7 @@
133
212
  console.info('[markstream-svelte][perf] parse(sync)', {
134
213
  ms: Math.round(performance.now() - start),
135
214
  nodes: nextParsedNodes.length,
136
- contentLength: content?.length ?? 0,
215
+ contentLength: renderContent?.length ?? 0,
137
216
  })
138
217
  }
139
218
  return nextParsedNodes
@@ -167,7 +246,7 @@
167
246
  void renderBatchDelay
168
247
  void renderBatchBudgetMs
169
248
  void renderBatchIdleTimeoutMs
170
- void final
249
+ void effectiveFinal
171
250
  void renderedNodeCount
172
251
  syncRenderedNodeWindow()
173
252
  })
@@ -175,7 +254,7 @@
175
254
 
176
255
  $effect(() => {
177
256
  void rootEl
178
- void final
257
+ void effectiveFinal
179
258
  void parsedNodes
180
259
  void renderedNodes
181
260
  void isDark
@@ -203,6 +282,7 @@
203
282
  enhancementHandle?.dispose()
204
283
  enhancementHandle = null
205
284
  disposeRenderedHtmlEnhancements(rootEl)
285
+ clearTypewriterCursorTimeout()
206
286
  })
207
287
 
208
288
  function toPositiveInteger(value: unknown, fallback: number) {
@@ -213,7 +293,7 @@
213
293
  function syncRenderedNodeWindow() {
214
294
  const total = parsedNodes.length
215
295
  const initialCount = Math.min(total, toPositiveInteger(initialRenderBatchSize, 40))
216
- const shouldBatch = final !== false && batchRendering !== false && total > initialCount
296
+ const shouldBatch = effectiveFinal !== false && batchRendering !== false && total > initialCount
217
297
 
218
298
  if (!shouldBatch) {
219
299
  cancelRenderBatch()
@@ -296,7 +376,7 @@
296
376
  async function scheduleEnhancement() {
297
377
  if (!rootEl || typeof window === 'undefined')
298
378
  return
299
- const enhancementFinal = typeof final === 'boolean' ? final : !hasLoadingNodes(parsedNodes)
379
+ const enhancementFinal = typeof effectiveFinal === 'boolean' ? effectiveFinal : !hasLoadingNodes(parsedNodes)
300
380
  if (!enhancementFinal) {
301
381
  enhancementToken += 1
302
382
  enhancementHandle?.dispose()
@@ -335,6 +415,156 @@
335
415
  return false
336
416
  }
337
417
 
418
+ // ── Typewriter cursor ──
419
+ let typewriterCursorEl: HTMLSpanElement | null = $state(null)
420
+ let showTypewriterCursor = $state(false)
421
+ let typewriterCursorTimeout: ReturnType<typeof setTimeout> | undefined
422
+ let lastTypewriterContentLength = 0
423
+ const TYPEWRITER_CURSOR_EXCLUDED_NODE_TYPES = new Set(['code_block', 'admonition', 'table', 'math_block', 'html_block', 'image'])
424
+
425
+ function shouldSkipTypewriterCursorForNode(node: unknown) {
426
+ if (!node || typeof node !== 'object')
427
+ return false
428
+ const type = (node as Record<string, unknown>).type
429
+ return typeof type === 'string' && TYPEWRITER_CURSOR_EXCLUDED_NODE_TYPES.has(type)
430
+ }
431
+
432
+ function shouldShowTypewriterCursorForCurrentNodes() {
433
+ const lastNode = parsedNodes[parsedNodes.length - 1]
434
+ return !shouldSkipTypewriterCursorForNode(lastNode)
435
+ }
436
+
437
+ function getNodeTextLength(node: unknown): number {
438
+ if (!node || typeof node !== 'object')
439
+ return 0
440
+ const record = node as Record<string, unknown>
441
+ const direct = record.raw ?? record.content ?? record.code
442
+ if (typeof direct === 'string')
443
+ return direct.length
444
+ const children = record.children
445
+ if (Array.isArray(children))
446
+ return children.reduce((total: number, child: unknown) => total + getNodeTextLength(child), 0)
447
+ const items = record.items
448
+ if (Array.isArray(items))
449
+ return items.reduce((total: number, item: unknown) => total + getNodeTextLength(item), 0)
450
+ return 0
451
+ }
452
+
453
+ function getTypewriterContentLength() {
454
+ if (nodes?.length)
455
+ return nodes.reduce((total: number, node: unknown) => total + getNodeTextLength(node), 0)
456
+ // Use raw content length, not renderContent (which may be the paced-out
457
+ // visible portion when smooth streaming is active).
458
+ return (content ?? '').length
459
+ }
460
+
461
+ function clearTypewriterCursorTimeout() {
462
+ if (!typewriterCursorTimeout)
463
+ return
464
+ clearTimeout(typewriterCursorTimeout)
465
+ typewriterCursorTimeout = undefined
466
+ }
467
+
468
+ function getLastTextNode(root: HTMLElement) {
469
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
470
+ acceptNode(node) {
471
+ const text = node.textContent ?? ''
472
+ if (!text.trim())
473
+ return NodeFilter.FILTER_REJECT
474
+ const parent = node.parentElement
475
+ if (!parent)
476
+ return NodeFilter.FILTER_REJECT
477
+ 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'))
478
+ return NodeFilter.FILTER_REJECT
479
+ return NodeFilter.FILTER_ACCEPT
480
+ },
481
+ })
482
+ let last: Text | null = null
483
+ let current = walker.nextNode()
484
+ while (current) {
485
+ last = current as Text
486
+ current = walker.nextNode()
487
+ }
488
+ return last
489
+ }
490
+
491
+ function updateTypewriterCursorPosition() {
492
+ if (typeof window === 'undefined' || !showTypewriterCursor || !rootEl || !typewriterCursorEl)
493
+ return
494
+ const root = rootEl
495
+ const cursor = typewriterCursorEl
496
+ const lastText = getLastTextNode(root)
497
+ const rootRect = root.getBoundingClientRect()
498
+ let left = 0
499
+ let top = 0
500
+ let height = 20
501
+
502
+ if (lastText?.textContent) {
503
+ const range = document.createRange()
504
+ const end = lastText.textContent.length
505
+ range.setStart(lastText, Math.max(0, end - 1))
506
+ range.setEnd(lastText, end)
507
+ const rects = typeof range.getClientRects === 'function'
508
+ ? range.getClientRects()
509
+ : undefined
510
+ const rect = rects?.[rects.length - 1] ?? lastText.parentElement?.getBoundingClientRect()
511
+ if (rect) {
512
+ left = rect.right - rootRect.left + root.scrollLeft
513
+ top = rect.top - rootRect.top + root.scrollTop
514
+ height = rect.height || height
515
+ }
516
+ range.detach()
517
+ }
518
+
519
+ cursor.style.transform = `translate(${Math.max(0, left)}px, ${Math.max(0, top)}px)`
520
+ cursor.style.height = `${height}px`
521
+ }
522
+
523
+ $effect(() => {
524
+ void renderContent
525
+ void nodes
526
+ void typewriter
527
+ void parsedNodes.length
528
+ void effectiveFinal
529
+ if (typeof window === 'undefined' || hasNodes)
530
+ return
531
+
532
+ // When the stream is final (and effective — smooth streaming has caught up),
533
+ // hide the cursor immediately.
534
+ if (effectiveFinal) {
535
+ showTypewriterCursor = false
536
+ clearTypewriterCursorTimeout()
537
+ return
538
+ }
539
+
540
+ const nextLength = getTypewriterContentLength()
541
+ const cursorAllowed = shouldShowTypewriterCursorForCurrentNodes()
542
+ if (typewriter === false || !cursorAllowed || nextLength <= lastTypewriterContentLength) {
543
+ if (typewriter === false || !cursorAllowed)
544
+ showTypewriterCursor = false
545
+ lastTypewriterContentLength = nextLength
546
+ return
547
+ }
548
+
549
+ lastTypewriterContentLength = nextLength
550
+ showTypewriterCursor = true
551
+ clearTypewriterCursorTimeout()
552
+ tick().then(() => {
553
+ updateTypewriterCursorPosition()
554
+ })
555
+ typewriterCursorTimeout = setTimeout(() => {
556
+ showTypewriterCursor = false
557
+ }, 3000)
558
+ })
559
+
560
+ $effect(() => {
561
+ if (!showTypewriterCursor)
562
+ return
563
+ tick().then(() => {
564
+ updateTypewriterCursorPosition()
565
+ })
566
+ })
567
+
338
568
  function handleMouseover(event: MouseEvent) {
339
569
  const target = event.target as HTMLElement | null
340
570
  if (target?.closest('[data-node-index]'))
@@ -362,9 +592,10 @@
362
592
  >
363
593
  {#each renderedNodes as node, index ((indexKey != null ? String(indexKey) : 'markdown-renderer') + '-' + index)}
364
594
  <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}>
595
+ <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
596
  <NodeOutlet node={node} context={renderContext} indexKey={(indexKey != null ? String(indexKey) : 'markdown-renderer') + '-' + index} />
367
597
  </div>
368
598
  </div>
369
599
  {/each}
600
+ {#if showTypewriterCursor}<span bind:this={typewriterCursorEl} class="typewriter-cursor" aria-hidden="true"></span>{/if}
370
601
  </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';
@@ -45,6 +46,7 @@ export type NodeRendererMermaidProps = Partial<{
45
46
  showCollapseButton: boolean;
46
47
  showZoomControls: boolean;
47
48
  isStrict: boolean;
49
+ enableMermaidInteractions: boolean;
48
50
  }> & Record<string, unknown>;
49
51
  export type NodeRendererD2Props = Partial<{
50
52
  maxHeight: string | null;
@@ -99,6 +101,7 @@ export interface NodeRendererProps {
99
101
  customId?: string;
100
102
  indexKey?: number | string;
101
103
  typewriter?: boolean;
104
+ fade?: boolean;
102
105
  batchRendering?: boolean;
103
106
  initialRenderBatchSize?: number;
104
107
  renderBatchSize?: number;
@@ -109,6 +112,8 @@ export interface NodeRendererProps {
109
112
  maxLiveNodes?: number;
110
113
  liveNodeBuffer?: number;
111
114
  allowHtml?: boolean;
115
+ smoothStreaming?: boolean | 'auto';
116
+ smoothStreamingOptions?: SmoothMarkdownStreamOptions;
112
117
  }
113
118
  export interface SvelteRenderContext {
114
119
  customId?: string;
@@ -116,6 +121,7 @@ export interface SvelteRenderContext {
116
121
  indexKey?: string;
117
122
  final?: boolean;
118
123
  typewriter?: boolean;
124
+ fade?: boolean;
119
125
  textStreamState?: Map<string, string>;
120
126
  streamRenderVersion?: number;
121
127
  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(optionsOrGetter?: SmoothMarkdownStreamOptions | (() => SmoothMarkdownStreamOptions)): SmoothMarkdownStreamControllerSvelte;
@@ -0,0 +1,41 @@
1
+ import { createSmoothMarkdownStream } from 'markstream-core';
2
+ import { onDestroy } from 'svelte';
3
+ export function useSmoothMarkdownStream(optionsOrGetter = {}) {
4
+ const options = typeof optionsOrGetter === 'function' ? optionsOrGetter() : optionsOrGetter;
5
+ let source = $state('');
6
+ let visible = $state('');
7
+ let done = $state(false);
8
+ let pendingChars = $state(0);
9
+ let caughtUp = $state(false);
10
+ let final = $state(false);
11
+ const controller = createSmoothMarkdownStream(options);
12
+ const sync = () => {
13
+ const snapshot = controller.getSnapshot();
14
+ source = snapshot.source;
15
+ visible = snapshot.visible;
16
+ done = snapshot.done;
17
+ pendingChars = snapshot.pendingChars;
18
+ caughtUp = snapshot.caughtUp;
19
+ final = snapshot.final;
20
+ };
21
+ const unsubscribe = controller.subscribe(sync);
22
+ sync();
23
+ onDestroy(() => {
24
+ unsubscribe();
25
+ controller.destroy();
26
+ });
27
+ return {
28
+ get source() { return source; },
29
+ get visible() { return visible; },
30
+ get done() { return done; },
31
+ get caughtUp() { return caughtUp; },
32
+ get final() { return final; },
33
+ get pendingChars() { return pendingChars; },
34
+ enqueue: chunk => controller.enqueue(chunk),
35
+ finish: opts => controller.finish(opts),
36
+ flush: () => controller.flush(),
37
+ reset: initialMarkdown => controller.reset(initialMarkdown),
38
+ pause: () => controller.pause(),
39
+ resume: () => controller.resume(),
40
+ };
41
+ }
@@ -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';
@@ -1,3 +1,4 @@
1
+ import { toSafeMermaidSvgMarkup } from 'stream-markdown-parser';
1
2
  import { getD2 } from './optional/d2';
2
3
  import { getInfographic } from './optional/infographic';
3
4
  import { getKatex } from './optional/katex';
@@ -212,7 +213,7 @@ async function renderKatexMarkup(source, displayMode) {
212
213
  return html;
213
214
  }
214
215
  async function renderMermaid(root, cleanupFns, options, isActive) {
215
- var _a, _b, _c, _d, _e, _f, _g;
216
+ var _a, _b, _c, _d, _e, _f, _g, _h;
216
217
  const strictMode = ((_a = options.mermaidProps) === null || _a === void 0 ? void 0 : _a.isStrict) !== false;
217
218
  const mermaid = await getMermaid({
218
219
  startOnLoad: false,
@@ -264,14 +265,18 @@ async function renderMermaid(root, cleanupFns, options, isActive) {
264
265
  const svg = typeof rendered === 'string' ? rendered : rendered === null || rendered === void 0 ? void 0 : rendered.svg;
265
266
  if (!svg)
266
267
  continue;
267
- const safeSvg = strictMode ? toSafeSvgMarkup(svg) : svg;
268
+ const safeSvg = toSafeMermaidSvgMarkup(svg);
268
269
  if (!safeSvg)
269
270
  continue;
270
271
  shell.body.innerHTML = safeSvg;
271
272
  shell.body.classList.add('markstream-svelte-mermaid');
272
273
  shell.wrapper.dataset.markstreamMermaid = '1';
273
- if (typeof rendered !== 'string')
274
- (_g = rendered === null || rendered === void 0 ? void 0 : rendered.bindFunctions) === null || _g === void 0 ? void 0 : _g.call(rendered, shell.body);
274
+ if (((_g = options.mermaidProps) === null || _g === void 0 ? void 0 : _g.enableMermaidInteractions) === true && typeof rendered !== 'string') {
275
+ try {
276
+ (_h = rendered === null || rendered === void 0 ? void 0 : rendered.bindFunctions) === null || _h === void 0 ? void 0 : _h.call(rendered, shell.body);
277
+ }
278
+ catch { }
279
+ }
275
280
  cleanupFns.push(() => {
276
281
  if (shell.wrapper.isConnected)
277
282
  shell.wrapper.replaceWith(originalPre.cloneNode(true));
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 {
@@ -1,4 +1,4 @@
1
- import { getMarkdown, isHtmlTagBlocked, isUnsafeHtmlUrl, NON_STRUCTURING_HTML_TAGS, normalizeCustomHtmlTagName, normalizeCustomHtmlTags, parseMarkdownToStructure, sanitizeHtmlAttrs, } from 'stream-markdown-parser';
1
+ import { getMarkdown, isHtmlTagBlocked, isUnsafeHtmlUrl, NON_STRUCTURING_HTML_TAGS, normalizeCustomHtmlTagName, normalizeCustomHtmlTags, parseMarkdownToStructure, sanitizeHtmlAttrs, sanitizeImageSrc, shouldOpenLinkInNewTab, } from 'stream-markdown-parser';
2
2
  import { hydrateCustomTagContent } from './hydrateCustomTagContent';
3
3
  import { sanitizeHtmlContent } from './sanitizeHtmlContent';
4
4
  const DEFAULT_CACHE_KEY = 'markstream-svelte-html';
@@ -224,8 +224,9 @@ function renderLinkNode(node, ctx) {
224
224
  ? renderNodesToHtml(getNodeList(node.children), ctx)
225
225
  : escapeHtml(getString(node.text || href));
226
226
  const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
227
- const hrefAttr = href && !isUnsafeHtmlUrl(href) ? ` href="${escapeAttr(href)}"` : '';
228
- const externalAttrs = href.startsWith('#') ? '' : ' target="_blank" rel="noreferrer noopener"';
227
+ const safeHref = href && !isUnsafeHtmlUrl(href) ? href : '';
228
+ const hrefAttr = safeHref ? ` href="${escapeAttr(safeHref)}"` : '';
229
+ const externalAttrs = shouldOpenLinkInNewTab(safeHref) ? ' target="_blank" rel="noreferrer noopener"' : '';
229
230
  return `<a${hrefAttr}${titleAttr}${externalAttrs}>${content}</a>`;
230
231
  }
231
232
  function renderMathInlineNode(node) {
@@ -237,7 +238,9 @@ function renderMathBlockNode(node) {
237
238
  return `<div class="markstream-nested-math-block"><pre class="markstream-nested-math-block__source"><code>${source}</code></pre><div class="markstream-nested-math-block__render" aria-hidden="true"></div></div>`;
238
239
  }
239
240
  function renderImageNode(node) {
240
- const src = getString(node.src);
241
+ const src = sanitizeImageSrc(node.src);
242
+ if (!src)
243
+ return '';
241
244
  const alt = getString(node.alt);
242
245
  const title = getString(node.title);
243
246
  const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
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.6",
5
5
  "description": "Svelte Markdown renderer for Markstream, aligned with markstream-vue and markstream-react.",
6
6
  "author": "Simon He",
7
7
  "license": "MIT",
@@ -49,7 +49,7 @@
49
49
  "@terrastruct/d2": ">=0.1.33",
50
50
  "katex": ">=0.16.22",
51
51
  "mermaid": ">=11",
52
- "stream-monaco": ">=0.0.40",
52
+ "stream-monaco": ">=0.0.41",
53
53
  "svelte": ">=5 <6"
54
54
  },
55
55
  "peerDependenciesMeta": {
@@ -71,25 +71,28 @@
71
71
  },
72
72
  "dependencies": {
73
73
  "@floating-ui/dom": "^1.7.6",
74
- "stream-markdown-parser": "0.0.93"
74
+ "markstream-core": "1.0.3",
75
+ "stream-markdown-parser": "1.0.5"
75
76
  },
76
77
  "devDependencies": {
77
78
  "@sveltejs/package": "^2.5.7",
78
79
  "@sveltejs/vite-plugin-svelte": "^6.2.4",
79
80
  "@types/node": "^18.19.130",
80
- "autoprefixer": "^10.4.27",
81
+ "autoprefixer": "^10.5.0",
81
82
  "postcss-cli": "^11.0.1",
82
- "svelte": "^5.55.5",
83
+ "svelte": "^5.55.7",
83
84
  "svelte-check": "^4.4.8",
84
85
  "tailwindcss": "^3.4.19",
85
86
  "typescript": "^5.9.3",
86
- "vite": "^7.3.1"
87
+ "vite": "^7.3.3"
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
+ "check:workspace-deps-published": "node ../../scripts/check-workspace-deps-published.mjs --package-json package.json",
96
+ "release": "pnpm run check:workspace-deps-published && bumpp --commit --no-tag --no-push && pnpm publish --access public && node ../../scripts/tag-package.mjs --package-json package.json --push"
94
97
  }
95
98
  }