svelte-streamdown 4.1.0 → 4.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -467,6 +467,9 @@ The animation system works by:
467
467
  2. **Sequential Animation**: Each token animates as it is received
468
468
  3. **Block-level Animation**: Entire blocks (paragraphs, headings, code blocks) animate as units
469
469
 
470
+ > [!NOTE]
471
+ > Only text that arrives in **streamed-sized appends** to `content` is animated. A bulk update — `content` replaced by a different document, a jump back to an earlier prefix, or a single append of more than ~2 KB such as pasting a whole answer or a "show all" — renders without animation, and the next streamed append animates again. Animating a whole document at once would start thousands of CSS animations in a single frame and stall the page.
472
+
470
473
  ### Animation Types
471
474
 
472
475
  Choose from 4 distinct animation styles:
package/dist/Block.svelte CHANGED
@@ -4,7 +4,7 @@
4
4
  import { lex, type StreamdownToken } from './marked/index.js';
5
5
  import AnimatedText from './AnimatedText.svelte';
6
6
  import { useStreamdown } from './context.svelte.js';
7
- import { getContext } from 'svelte';
7
+ import { getContext, untrack } from 'svelte';
8
8
 
9
9
  let {
10
10
  block,
@@ -21,9 +21,13 @@
21
21
  // The old code never consulted `streamdown.parseIncompleteMarkdown`; the import
22
22
  // is aliased so the context flag and the helper cannot be confused.
23
23
  const complete = $derived(!isStatic && streamdown.parseIncompleteMarkdown !== false);
24
- const tokens = $derived(
25
- lex(complete ? completeMarkdown(block.trim()) : block, streamdown.extensions)
26
- );
24
+ const view = $derived.by(() => {
25
+ const tokens = lex(complete ? completeMarkdown(block.trim()) : block, streamdown.extensions);
26
+ // Decided when this block's text changes and deliberately not tracked: a
27
+ // bulk update renders plain, and the next streamed chunk must not
28
+ // retroactively animate the blocks it left untouched.
29
+ return { tokens, animate: untrack(() => streamdown.animateUpdate) };
30
+ });
27
31
  const insidePopover = getContext('POPOVER');
28
32
  </script>
29
33
 
@@ -32,9 +36,9 @@
32
36
  {#if token}
33
37
  {@const children = (token as any)?.tokens || []}
34
38
  {@const isTextOnlyNode = children.length === 0}
35
- <Element {token} {incomplete}>
39
+ <Element {token} {incomplete} animate={view.animate}>
36
40
  {#if isTextOnlyNode}
37
- {#if streamdown.animation.enabled && !insidePopover && !isStatic}
41
+ {#if streamdown.animation.enabled && view.animate && !insidePopover && !isStatic}
38
42
  <AnimatedText text={'text' in token ? token.text || '' : ''} />
39
43
  {:else}
40
44
  {'text' in token ? token.text : ''}
@@ -47,4 +51,4 @@
47
51
  {/each}
48
52
  {/snippet}
49
53
 
50
- {@render renderChildren(tokens)}
54
+ {@render renderChildren(view.tokens)}
@@ -11,12 +11,15 @@
11
11
  const {
12
12
  token,
13
13
  id,
14
- incomplete = false
14
+ incomplete = false,
15
+ animate = true
15
16
  }: {
16
17
  token: CodeToken;
17
18
  id: string;
18
19
  /** The fence is still being streamed; nothing below it is final yet. */
19
20
  incomplete?: boolean;
21
+ /** False for the render of a bulk update — a replacement or a paste-sized append (see Block). */
22
+ animate?: boolean;
20
23
  } = $props();
21
24
 
22
25
  const streamdown = useStreamdown();
@@ -66,7 +69,7 @@
66
69
  <div
67
70
  data-streamdown-code={id}
68
71
  data-incomplete={incomplete || undefined}
69
- style={streamdown.isMounted ? streamdown.animationBlockStyle : ''}
72
+ style={animate && streamdown.isMounted ? streamdown.animationBlockStyle : ''}
70
73
  class={streamdown.theme.code.base}
71
74
  >
72
75
  <div class={streamdown.theme.code.header}>
@@ -115,7 +118,7 @@
115
118
  >{#each lines as line}<span class={streamdown.theme.code.line}
116
119
  >{#if line.length === 0}&#8203;{/if}{#each line as t}<span
117
120
  class="th-token{t.className ? ` th-${t.className}` : ''}"
118
- style={streamdown.isMounted ? streamdown.animationTextStyle : ''}
121
+ style={animate && streamdown.isMounted ? streamdown.animationTextStyle : ''}
119
122
  style:color={streamdown.highlightTheme.tokens[t.className ?? 'token']}
120
123
  >{t.value}</span
121
124
  >{/each}</span
@@ -4,6 +4,8 @@ type $$ComponentProps = {
4
4
  id: string;
5
5
  /** The fence is still being streamed; nothing below it is final yet. */
6
6
  incomplete?: boolean;
7
+ /** False for the render of a bulk update — a replacement or a paste-sized append (see Block). */
8
+ animate?: boolean;
7
9
  };
8
10
  declare const Code: import("svelte").Component<$$ComponentProps, {}, "">;
9
11
  type Code = ReturnType<typeof Code>;
@@ -15,8 +15,14 @@
15
15
  let {
16
16
  token,
17
17
  children,
18
- incomplete = false
19
- }: { token: StreamdownToken; children: Snippet; incomplete?: boolean } = $props();
18
+ incomplete = false,
19
+ animate = true
20
+ }: {
21
+ token: StreamdownToken;
22
+ children: Snippet;
23
+ incomplete?: boolean;
24
+ animate?: boolean;
25
+ } = $props();
20
26
  const streamdown = useStreamdown();
21
27
 
22
28
  // Use provided components or fallback to lightweight versions
@@ -25,7 +31,7 @@
25
31
  const MathComponent = $derived(streamdown.components?.math ?? MathFallback);
26
32
 
27
33
  // Only apply animation on block level elements. Leaves text elements to be animated by their text children.
28
- const style = $derived(streamdown.isMounted ? streamdown.animationBlockStyle : '');
34
+ const style = $derived(animate && streamdown.isMounted ? streamdown.animationBlockStyle : '');
29
35
  const id = $props.id();
30
36
 
31
37
  // Only ever attached to the table wrapper, which is the element that already
@@ -91,11 +97,11 @@
91
97
  props={{ children, token, incomplete }}
92
98
  render={streamdown.snippets.mermaid ?? streamdown.snippets.code}
93
99
  >
94
- <MermaidComponent {id} {token} {incomplete} />
100
+ <MermaidComponent {id} {token} {incomplete} {animate} />
95
101
  </Slot>
96
102
  {:else if token.type === 'code'}
97
103
  <Slot props={{ children, token, incomplete }} render={streamdown.snippets.code}>
98
- <CodeComponent {id} {token} {incomplete} />
104
+ <CodeComponent {id} {token} {incomplete} {animate} />
99
105
  </Slot>
100
106
  {:else if token.type === 'codespan'}
101
107
  <Slot props={{ children, token }} render={streamdown.snippets.codespan}>
@@ -284,7 +290,7 @@
284
290
  <Slot props={{ children, token }} render={streamdown.snippets.descriptionList}>
285
291
  <dl
286
292
  data-streamdown-description-list={id}
287
- style={streamdown.animationBlockStyle}
293
+ style={animate ? streamdown.animationBlockStyle : ''}
288
294
  class={streamdown.theme.descriptionList.base}
289
295
  >
290
296
  {@render children()}
@@ -4,6 +4,7 @@ type $$ComponentProps = {
4
4
  token: StreamdownToken;
5
5
  children: Snippet;
6
6
  incomplete?: boolean;
7
+ animate?: boolean;
7
8
  };
8
9
  declare const Element: import("svelte").Component<$$ComponentProps, {}, "">;
9
10
  type Element = ReturnType<typeof Element>;
@@ -13,12 +13,14 @@
13
13
  const {
14
14
  token,
15
15
  id,
16
- incomplete = false
16
+ incomplete = false,
17
+ animate = true
17
18
  }: {
18
19
  token: CodeToken;
19
20
  id: string;
20
21
  /** The fence is still being streamed; nothing below it is final yet. */
21
22
  incomplete?: boolean;
23
+ animate?: boolean;
22
24
  } = $props();
23
25
 
24
26
  // Trailing blank lines are noise for mermaid but they still changed `token.text`
@@ -251,7 +253,7 @@
251
253
  {#if mermaid}
252
254
  <div
253
255
  bind:this={container}
254
- style={streamdown.isMounted ? streamdown.animationBlockStyle : ''}
256
+ style={animate && streamdown.isMounted ? streamdown.animationBlockStyle : ''}
255
257
  class={streamdown.theme.mermaid.base}
256
258
  {@attach (node) => {
257
259
  // A half-written diagram makes mermaid throw and log on every chunk, so
@@ -4,6 +4,7 @@ type $$ComponentProps = {
4
4
  id: string;
5
5
  /** The fence is still being streamed; nothing below it is final yet. */
6
6
  incomplete?: boolean;
7
+ animate?: boolean;
7
8
  };
8
9
  declare const Mermaid: import("svelte").Component<$$ComponentProps, {}, "">;
9
10
  type Mermaid = ReturnType<typeof Mermaid>;
@@ -6,12 +6,14 @@
6
6
  const {
7
7
  token,
8
8
  id,
9
- incomplete = false
9
+ incomplete = false,
10
+ animate = true
10
11
  }: {
11
12
  token: Tokens.Code;
12
13
  id: string;
13
14
  /** The fence is still being streamed; nothing below it is final yet. */
14
15
  incomplete?: boolean;
16
+ animate?: boolean;
15
17
  } = $props();
16
18
 
17
19
  const streamdown = useStreamdown();
@@ -36,7 +38,7 @@
36
38
  <div
37
39
  data-streamdown-code={id}
38
40
  data-incomplete={incomplete || undefined}
39
- style={streamdown.isMounted ? streamdown.animationBlockStyle : ''}
41
+ style={animate && streamdown.isMounted ? streamdown.animationBlockStyle : ''}
40
42
  class={streamdown.theme.code.base}
41
43
  >
42
44
  <div class={streamdown.theme.code.header}>
@@ -49,7 +51,7 @@
49
51
  style:overflow-y={streamdown.codeBlockMaxHeight ? 'auto' : undefined}
50
52
  {@attach pinnedScroll}><code
51
53
  >{#each code.split('\n') as line}<span class={streamdown.theme.code.line}
52
- ><span style={streamdown.isMounted ? streamdown.animationTextStyle : ''}
54
+ ><span style={animate && streamdown.isMounted ? streamdown.animationTextStyle : ''}
53
55
  >{line.trim().length > 0 ? line : '\u200B'}</span
54
56
  ></span
55
57
  >{/each}</code
@@ -4,6 +4,7 @@ type $$ComponentProps = {
4
4
  id: string;
5
5
  /** The fence is still being streamed; nothing below it is final yet. */
6
6
  incomplete?: boolean;
7
+ animate?: boolean;
7
8
  };
8
9
  declare const CodeFallback: import("svelte").Component<$$ComponentProps, {}, "">;
9
10
  type CodeFallback = ReturnType<typeof CodeFallback>;
@@ -55,6 +55,10 @@
55
55
  mermaidConfig?.theme ? mermaidConfig.theme : darkMode.current ? 'dark' : 'default'
56
56
  );
57
57
 
58
+ // Per-instance incremental state: append-only content updates re-lex only
59
+ // the last couple of blocks instead of the whole document.
60
+ const blocksCache = createParseBlocksCache();
61
+
58
62
  streamdown = new StreamdownContext({
59
63
  get element() {
60
64
  return element;
@@ -77,6 +81,12 @@
77
81
  get highlightTheme() {
78
82
  return resolvedHighlightTheme;
79
83
  },
84
+ get animateUpdate() {
85
+ // A streamed chunk keeps animating; a replacement or a bulk append (the
86
+ // demo's "Show All" is an append of 95% of the document) renders plain.
87
+ // The first parse stays animatable so `animateOnMount` keeps its meaning.
88
+ return blocksCache.lastUpdate !== 'bulk';
89
+ },
80
90
  get snippets() {
81
91
  return snippets;
82
92
  },
@@ -200,9 +210,6 @@
200
210
 
201
211
  const id = $props.id();
202
212
 
203
- // Per-instance incremental state: append-only content updates re-lex only
204
- // the last couple of blocks instead of the whole document.
205
- const blocksCache = createParseBlocksCache();
206
213
  const blocks = $derived(
207
214
  isStatic ? content : parseBlocks(content, streamdown.extensions, blocksCache)
208
215
  );
@@ -50,6 +50,8 @@ export declare const defaultTranslations: Translations;
50
50
  export interface StreamdownContext extends Omit<StreamdownProps, keyof Snippets | 'class' | 'theme' | 'highlightTheme' | 'inlineCitationsMode'> {
51
51
  snippets: Snippets;
52
52
  highlightTheme: HighlightTheme;
53
+ /** False while rendering a bulk update (a replacement or a paste-sized append); Block captures it per update. */
54
+ animateUpdate: boolean;
53
55
  theme: Theme;
54
56
  translations: Translations;
55
57
  controls: ResolvedControls;
@@ -69,6 +71,7 @@ export declare class StreamdownContext<Source extends Record<string, any> = Reco
69
71
  constructor(props: Omit<StreamdownProps, keyof Snippets | 'class' | 'highlightTheme'> & {
70
72
  snippets: Snippets<Source>;
71
73
  highlightTheme: HighlightTheme;
74
+ animateUpdate: boolean;
72
75
  });
73
76
  }
74
77
  export declare const useStreamdown: () => StreamdownContext<Record<string, any>>;
@@ -246,11 +249,13 @@ export type StreamdownProps<Source extends Record<string, any> = Record<string,
246
249
  token: Tokens.Code;
247
250
  id: string;
248
251
  incomplete: boolean;
252
+ animate: boolean;
249
253
  }, any, any>;
250
254
  mermaid?: Component<{
251
255
  token: Tokens.Code;
252
256
  id: string;
253
257
  incomplete: boolean;
258
+ animate: boolean;
254
259
  }, any, any>;
255
260
  math?: Component<{
256
261
  token: MathToken;
@@ -57,6 +57,12 @@ export type ParseBlocksCache = {
57
57
  keptBefore: number[];
58
58
  /** persistent list of kept raws; only its tail is rewritten on append */
59
59
  blocks: string[];
60
+ /**
61
+ * What kind of update the last call was. Rendering skips the streaming
62
+ * animation on a `bulk` one — a replacement, or an append far larger than any
63
+ * streamed chunk — where ~10k spans would otherwise all animate in one frame.
64
+ */
65
+ lastUpdate: 'first' | 'stream' | 'bulk';
60
66
  };
61
67
  export declare const createParseBlocksCache: () => ParseBlocksCache;
62
68
  export declare const parseBlocks: (markdown: string, extensions?: Extension[], cache?: ParseBlocksCache) => string[];
@@ -145,7 +145,8 @@ export const createParseBlocksCache = () => ({
145
145
  keep: [],
146
146
  offsets: [0],
147
147
  keptBefore: [0],
148
- blocks: []
148
+ blocks: [],
149
+ lastUpdate: 'first'
149
150
  });
150
151
  // Number of trailing rendered blocks that stay "live" (re-lexed every chunk).
151
152
  // 2 covers constructs that merge backward as they stream in — e.g. a paragraph
@@ -225,7 +226,23 @@ const appendable = (markdown, cache, cut, offset) => {
225
226
  }
226
227
  return markdown.charCodeAt(offset - 1) === content.charCodeAt(offset - 1);
227
228
  };
229
+ // An append this large in a single update is a paste or a "show all", not a
230
+ // streamed chunk. Real streams arrive in tens of characters, and even a client
231
+ // that batches renders to one frame at ~1000 tokens/s adds a few hundred; the
232
+ // harm — thousands of spans starting a CSS animation in the same frame — only
233
+ // begins well past this. ponytail: heuristic; a prop if anyone needs to tune it.
234
+ const BULK_APPEND_CHARS = 2048;
235
+ const updateKind = (isAppend, previousLength, length) => {
236
+ if (!isAppend)
237
+ return previousLength === 0 ? 'first' : 'bulk';
238
+ return length - previousLength > BULK_APPEND_CHARS ? 'bulk' : 'stream';
239
+ };
228
240
  export const parseBlocks = (markdown, extensions = [], cache) => {
241
+ // Whether this call extends the content the cache already described — decided
242
+ // by the same probe the fast path uses, so the contiguity fallback below still
243
+ // counts as an append for the animation's purposes.
244
+ let isAppend = false;
245
+ const previousLength = cache?.content.length ?? 0;
229
246
  if (cache && cache.content.length > 0 && markdown.length > cache.content.length) {
230
247
  // Append-only update: seal everything except the last SEAL_SLACK rendered
231
248
  // blocks and re-lex only the tail. offsets[] are prefix sums over raws, so
@@ -239,6 +256,7 @@ export const parseBlocks = (markdown, extensions = [], cache) => {
239
256
  }
240
257
  const offset = cache.offsets[cut];
241
258
  if (appendable(markdown, cache, cut, offset)) {
259
+ isAppend = true;
242
260
  const tailTokens = blockTokensOf(markdown.slice(offset), extensions);
243
261
  let tailLength = 0;
244
262
  for (const token of tailTokens)
@@ -268,6 +286,7 @@ export const parseBlocks = (markdown, extensions = [], cache) => {
268
286
  cache.keptBefore.push(kept);
269
287
  }
270
288
  cache.content = markdown;
289
+ cache.lastUpdate = updateKind(isAppend, previousLength, markdown.length);
271
290
  // Copy out: callers (Svelte `$derived`, the perf harness) diff block
272
291
  // lists by identity, so handing back the persistent array would read as
273
292
  // "nothing changed". slice() is a memcpy with no per-element callback.
@@ -298,6 +317,7 @@ export const parseBlocks = (markdown, extensions = [], cache) => {
298
317
  }
299
318
  cache.keptBefore.push(kept);
300
319
  }
320
+ cache.lastUpdate = updateKind(isAppend, previousLength, markdown.length);
301
321
  // Only trust the cache for future appends if raws reconstruct the input.
302
322
  cache.content = pos === markdown.length ? markdown : '';
303
323
  return cache.blocks.slice();
@@ -1,7 +1,12 @@
1
- /** An open code fence: the character it was opened with, and its run length. */
1
+ /**
2
+ * An open code fence: the character it was opened with, its run length, and
3
+ * the prefix the opener sat behind (indentation, blockquote markers, a list
4
+ * marker). The prefix is what a closer's indentation is measured against.
5
+ */
2
6
  export type OpenFence = {
3
7
  char: string;
4
8
  length: number;
9
+ prefix: string;
5
10
  } | null;
6
11
  /**
7
12
  * Fence tracking, one line at a time: returns the fence state after `line`.
@@ -9,6 +14,12 @@ export type OpenFence = {
9
14
  * "am I inside a fence" answer can never drift between them.
10
15
  */
11
16
  export declare const trackFence: (line: string, open: OpenFence) => OpenFence;
17
+ /**
18
+ * The line that closes `open`, placed at the opener's depth so it closes the
19
+ * fence where it lives. A list marker in the prefix is blanked to spaces: a
20
+ * closer must sit inside the item, and a repeated marker would start a new one.
21
+ */
22
+ export declare const closingFence: (open: NonNullable<OpenFence>) => string;
12
23
  /**
13
24
  * True when `raw` ends inside a fenced block — i.e. the fence is still being
14
25
  * streamed. Only the last block of a live document can be in this state.
@@ -1,31 +1,48 @@
1
- // Up to 3 spaces of indentation per CommonMark; blockquote markers are stripped
2
- // too because a fence can be quoted ("> ```") inside a blockquote or an alert,
3
- // and so is a leading list marker, because "- ```js" opens a fence in the item.
4
- const FENCE_LINE = /^[ \t]{0,3}(?:>[ \t]*)*(?:(?:[-*+]|\d{1,9}[.)])[ \t]+)?(`{3,}|~{3,})(.*)$/;
1
+ // An opener may sit behind up to 3 spaces of indentation per CommonMark, behind
2
+ // blockquote markers ("> ```" inside a blockquote or an alert), and behind a
3
+ // list marker, because "- ```js" opens a fence in the item.
4
+ const OPENER_LINE = /^([ \t]{0,3}(?:>[ \t]*)*(?:(?:[-*+]|\d{1,9}[.)])[ \t]+)?)(`{3,}|~{3,})(.*)$/;
5
+ // A closer is measured against the opener, not against column 0: it may be
6
+ // indented up to 3 spaces more than the opener was. That is what makes a fence
7
+ // inside a list item close — "10. ```js" opens at column 4, so its " ```"
8
+ // closer is at 4, which a fixed 0–3 limit rejected.
9
+ const CLOSER_LINE = /^([ \t]*(?:>[ \t]*)*)(`{3,}|~{3,})[ \t]*$/;
5
10
  /**
6
11
  * Fence tracking, one line at a time: returns the fence state after `line`.
7
12
  * The completer's block scan and `hasUnclosedFence` both walk with this, so the
8
13
  * "am I inside a fence" answer can never drift between them.
9
14
  */
10
15
  export const trackFence = (line, open) => {
11
- const match = FENCE_LINE.exec(line);
12
- if (!match) {
13
- return open;
14
- }
15
- const run = match[1];
16
- const info = match[2];
17
16
  if (open) {
18
- // A closer uses the opener's character, is at least as long, and carries no
19
- // info string so a shorter fence line inside a longer block is content.
20
- return run[0] === open.char && run.length >= open.length && info.trim() === '' ? null : open;
17
+ // A closer uses the opener's character, is at least as long, carries no
18
+ // info string, and is not indented deeper than the opener plus 3 so a
19
+ // shorter or deeper fence line inside the block is content.
20
+ const match = CLOSER_LINE.exec(line);
21
+ if (!match)
22
+ return open;
23
+ const run = match[2];
24
+ const closes = run[0] === open.char &&
25
+ run.length >= open.length &&
26
+ match[1].length <= open.prefix.length + 3;
27
+ return closes ? null : open;
21
28
  }
29
+ const match = OPENER_LINE.exec(line);
30
+ if (!match)
31
+ return null;
32
+ const run = match[2];
22
33
  // A backtick fence's info string cannot contain a backtick (marked's own rule),
23
34
  // so such a line opens nothing.
24
- if (run[0] === '`' && info.includes('`')) {
25
- return open;
26
- }
27
- return { char: run[0], length: run.length };
35
+ if (run[0] === '`' && match[3].includes('`'))
36
+ return null;
37
+ return { char: run[0], length: run.length, prefix: match[1] };
28
38
  };
39
+ /**
40
+ * The line that closes `open`, placed at the opener's depth so it closes the
41
+ * fence where it lives. A list marker in the prefix is blanked to spaces: a
42
+ * closer must sit inside the item, and a repeated marker would start a new one.
43
+ */
44
+ export const closingFence = (open) => open.prefix.replace(/[-*+]|\d{1,9}[.)]/g, (marker) => ' '.repeat(marker.length)) +
45
+ open.char.repeat(open.length);
29
46
  /**
30
47
  * True when `raw` ends inside a fenced block — i.e. the fence is still being
31
48
  * streamed. Only the last block of a live document can be in this state.
@@ -1,4 +1,4 @@
1
- import { trackFence } from './fence.js';
1
+ import { closingFence, trackFence } from './fence.js';
2
2
  export class IncompleteMarkdownParser {
3
3
  plugins = [];
4
4
  state = {
@@ -196,10 +196,12 @@ export class IncompleteMarkdownParser {
196
196
  // Close inner blocks (code/math) before alignment wrappers.
197
197
  let result = text;
198
198
  if (state.blockingContexts.has('code')) {
199
- // Close with the fence that was opened: a '~~~' block is not closed by
200
- // '```', and a longer run needs a closer at least as long.
199
+ // Close with the fence that was opened, at the depth it was opened: a
200
+ // '~~~' block is not closed by '```', a longer run needs a closer at
201
+ // least as long, and a fence inside a list item needs its closer inside
202
+ // the item — one at column 0 leaves the block open and starts a new one.
201
203
  const fence = state.openFence;
202
- result += '\n' + (fence ? fence.char.repeat(fence.length) : '```');
204
+ result += '\n' + (fence ? closingFence(fence) : '```');
203
205
  }
204
206
  if (state.blockingContexts.has('math')) {
205
207
  if (state.mathCloser === '\\]') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-streamdown",
3
- "version": "4.1.0",
3
+ "version": "4.1.1",
4
4
  "packageManager": "pnpm@10.32.1",
5
5
  "repository": {
6
6
  "type": "git",