@vendure-io/ui 2.0.0-beta.6 → 2.0.0-beta.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vendure-io/ui",
3
- "version": "2.0.0-beta.6",
3
+ "version": "2.0.0-beta.8",
4
4
  "description": "React component library for Vendure, built on shadcn/ui and Tailwind v4",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "homepage": "https://github.com/vendurehq/design/tree/main/packages/ui",
@@ -45,8 +45,11 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@base-ui/react": "^1.2.0",
48
+ "@shikijs/langs": "^3.23.0",
49
+ "@shikijs/themes": "^3.23.0",
50
+ "@shikijs/transformers": "^3.22.0",
48
51
  "@tanstack/react-table": "^8.21.3",
49
- "@vendure-io/design-tokens": "^2.0.0-beta.5",
52
+ "@vendure-io/design-tokens": "^2.0.0-beta.7",
50
53
  "class-variance-authority": "^0.7.1",
51
54
  "clsx": "^2.1.1",
52
55
  "cmdk": "^1.1.1",
@@ -58,6 +61,7 @@
58
61
  "react-day-picker": "^9.14.0",
59
62
  "react-resizable-panels": "^4.7.0",
60
63
  "recharts": "2.15.4",
64
+ "shiki": "^3.22.0",
61
65
  "sonner": "^2.0.7",
62
66
  "tailwind-merge": "^3.5.0",
63
67
  "vaul": "^1.1.2"
@@ -6,7 +6,7 @@ import { cva, type VariantProps } from "class-variance-authority"
6
6
  import { cn } from "@vendure-io/ui/lib/utils"
7
7
 
8
8
  const buttonVariants = cva(
9
- "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all duration-(--transition-duration-fast) ease-(--ease-default) data-pressed:brightness-95 dark:data-pressed:brightness-110 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
9
+ "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all duration-(--transition-duration-fast) ease-(--ease-default) data-pressed:brightness-95 dark:data-pressed:brightness-110 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none cursor-pointer",
10
10
  {
11
11
  variants: {
12
12
  variant: {
@@ -53,9 +53,9 @@ interface AnonymizedTokenProps extends Omit<React.ComponentProps<'span'>, 'child
53
53
  copyable?: boolean;
54
54
  /** How long the check-mark feedback stays visible, in ms. */
55
55
  timeout?: CopyButtonProps['timeout'];
56
- /** Called after a successful copy. Wire your toast here. */
56
+ /** Called after a successful copy. Wire your toast here. Falls back to `CopyFeedbackProvider`. */
57
57
  onCopied?: CopyButtonProps['onCopied'];
58
- /** Called when the clipboard write fails. */
58
+ /** Called when the clipboard write fails. Falls back to `CopyFeedbackProvider`. */
59
59
  onCopyError?: CopyButtonProps['onCopyError'];
60
60
  /** Accessible label before copying. @default "Copy token" */
61
61
  copyLabel?: CopyButtonProps['copyLabel'];
@@ -0,0 +1,1124 @@
1
+ 'use client';
2
+
3
+ import {
4
+ transformerNotationDiff,
5
+ transformerNotationErrorLevel,
6
+ transformerNotationFocus,
7
+ transformerNotationHighlight,
8
+ transformerNotationWordHighlight,
9
+ } from '@shikijs/transformers';
10
+ import { Button } from '@vendure-io/ui/components/atoms/button';
11
+ import { ScrollArea, ScrollBar } from '@vendure-io/ui/components/atoms/scroll-area';
12
+ import { Tooltip, TooltipContent, TooltipTrigger } from '@vendure-io/ui/components/atoms/tooltip';
13
+ import { useCopyFeedback } from '@vendure-io/ui/components/molecules/copy-feedback-provider';
14
+ import { useCopy } from '@vendure-io/ui/hooks/use-copy';
15
+ import { cn } from '@vendure-io/ui/lib/utils';
16
+ import {
17
+ CheckIcon,
18
+ ChevronDownIcon,
19
+ ChevronUpIcon,
20
+ CodeIcon,
21
+ CopyIcon,
22
+ FileIcon,
23
+ TerminalIcon,
24
+ } from 'lucide-react';
25
+ import { motion } from 'motion/react';
26
+ import {
27
+ type ComponentProps,
28
+ type PropsWithChildren,
29
+ type ReactNode,
30
+ useEffect,
31
+ useId,
32
+ useMemo,
33
+ useRef,
34
+ useState,
35
+ } from 'react';
36
+ import { createHighlighterCore } from 'shiki/core';
37
+ import { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
38
+
39
+ // =============================================================================
40
+ // Types
41
+ // =============================================================================
42
+
43
+ type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun';
44
+
45
+ type CodeBlockProps = PropsWithChildren<ComponentProps<'div'>> & {
46
+ /** Programming language for syntax highlighting */
47
+ language?: string;
48
+ /**
49
+ * Filename shown in the header (overrides a `// filename:` first-line directive).
50
+ * Takes precedence over the package-manager tabs. A block with a filename never
51
+ * shows the switcher, even when `packageManagerSwitcher` is set.
52
+ */
53
+ filename?: string;
54
+ /** Hide the header bar completely */
55
+ hideHeader?: boolean;
56
+ /** Enable npm→pnpm/yarn/bun tab switching for shell blocks containing npm/npx commands. @default false */
57
+ packageManagerSwitcher?: boolean;
58
+ /** Extra toolbar actions rendered before the built-in copy button. Compose with CodeBlockAction. */
59
+ actions?: ReactNode;
60
+ /**
61
+ * Called after a successful copy. Wire your toast here. The DS never toasts.
62
+ * Function props can't cross an RSC boundary — when rendering from a server
63
+ * component, omit this and mount `CopyFeedbackProvider` instead; it is the
64
+ * fallback when no prop is passed.
65
+ */
66
+ onCopied?: () => void;
67
+ /** Called when the clipboard write fails. Falls back to `CopyFeedbackProvider`. */
68
+ onCopyError?: (error: Error) => void;
69
+ };
70
+
71
+ interface CodeBlockActionProps {
72
+ icon: ReactNode;
73
+ label: string;
74
+ onClick: () => void;
75
+ disabled?: boolean;
76
+ }
77
+
78
+ // =============================================================================
79
+ // Constants
80
+ // =============================================================================
81
+
82
+ const PACKAGE_MANAGERS: PackageManager[] = ['npm', 'pnpm', 'yarn', 'bun'];
83
+ const STORAGE_KEY = 'vendure-ui-package-manager';
84
+ const STORAGE_EVENT = 'vendure-ui-package-manager-change';
85
+
86
+ // Expand/collapse constants
87
+ const COLLAPSE_THRESHOLD = 40; // Lines needed to trigger collapse
88
+ const COLLAPSED_HEIGHT = 300; // px height when collapsed (roughly 15-20 lines)
89
+
90
+ const languageLoaders = {
91
+ bash: () => import('@shikijs/langs/bash').then((module) => module.default),
92
+ css: () => import('@shikijs/langs/css').then((module) => module.default),
93
+ dotenv: () => import('@shikijs/langs/dotenv').then((module) => module.default),
94
+ graphql: () => import('@shikijs/langs/graphql').then((module) => module.default),
95
+ html: () => import('@shikijs/langs/html').then((module) => module.default),
96
+ ini: () => import('@shikijs/langs/ini').then((module) => module.default),
97
+ javascript: () => import('@shikijs/langs/javascript').then((module) => module.default),
98
+ json: () => import('@shikijs/langs/json').then((module) => module.default),
99
+ jsonc: () => import('@shikijs/langs/jsonc').then((module) => module.default),
100
+ jsx: () => import('@shikijs/langs/jsx').then((module) => module.default),
101
+ markdown: () => import('@shikijs/langs/markdown').then((module) => module.default),
102
+ mdx: () => import('@shikijs/langs/mdx').then((module) => module.default),
103
+ python: () => import('@shikijs/langs/python').then((module) => module.default),
104
+ shellscript: () => import('@shikijs/langs/shellscript').then((module) => module.default),
105
+ sql: () => import('@shikijs/langs/sql').then((module) => module.default),
106
+ tsx: () => import('@shikijs/langs/tsx').then((module) => module.default),
107
+ typescript: () => import('@shikijs/langs/typescript').then((module) => module.default),
108
+ yaml: () => import('@shikijs/langs/yaml').then((module) => module.default),
109
+ } as const;
110
+
111
+ type SupportedLanguage = keyof typeof languageLoaders;
112
+
113
+ const themeLoaders = [
114
+ () => import('@shikijs/themes/github-light').then((module) => module.default),
115
+ () => import('@shikijs/themes/github-dark-default').then((module) => module.default),
116
+ ] as const;
117
+
118
+ let highlighterPromise: ReturnType<typeof createHighlighterCore> | null = null;
119
+
120
+ function getHighlighter(): ReturnType<typeof createHighlighterCore> {
121
+ highlighterPromise ??= Promise.all([
122
+ Promise.all(themeLoaders.map((loadTheme) => loadTheme())),
123
+ Promise.all(Object.values(languageLoaders).map((loadLanguage) => loadLanguage())),
124
+ ]).then(([themes, languages]) =>
125
+ createHighlighterCore({
126
+ themes,
127
+ langs: languages.flat(),
128
+ engine: createJavaScriptRegexEngine(),
129
+ }),
130
+ );
131
+
132
+ return highlighterPromise;
133
+ }
134
+
135
+ const highlightedCodeCache = new Map<string, Promise<string>>();
136
+
137
+ /**
138
+ * Get the stored package manager preference from localStorage
139
+ */
140
+ function getStoredPackageManager(): PackageManager {
141
+ if (typeof window === 'undefined') return 'npm';
142
+ try {
143
+ const stored = localStorage.getItem(STORAGE_KEY);
144
+ if (stored && PACKAGE_MANAGERS.includes(stored as PackageManager)) {
145
+ return stored as PackageManager;
146
+ }
147
+ } catch {
148
+ // localStorage might not be available
149
+ }
150
+ return 'npm';
151
+ }
152
+
153
+ /**
154
+ * Store the package manager preference in localStorage
155
+ */
156
+ function setStoredPackageManager(pm: PackageManager): void {
157
+ if (typeof window === 'undefined') return;
158
+ try {
159
+ localStorage.setItem(STORAGE_KEY, pm);
160
+ // Dispatch a custom event so other CodeBlock instances can update
161
+ window.dispatchEvent(new CustomEvent(STORAGE_EVENT, { detail: pm }));
162
+ } catch {
163
+ // localStorage might not be available
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Custom hook to manage package manager state with localStorage persistence
169
+ */
170
+ function usePackageManager(): [PackageManager, (pm: PackageManager) => void] {
171
+ const [packageManager, setPackageManager] = useState<PackageManager>('npm');
172
+
173
+ // Initialize from localStorage on mount
174
+ useEffect(() => {
175
+ setPackageManager(getStoredPackageManager());
176
+ }, []);
177
+
178
+ // Listen for changes from other CodeBlock instances
179
+ useEffect(() => {
180
+ const handleChange = (e: CustomEvent<PackageManager>) => {
181
+ setPackageManager(e.detail);
182
+ };
183
+
184
+ window.addEventListener(STORAGE_EVENT, handleChange as EventListener);
185
+ return () => {
186
+ window.removeEventListener(STORAGE_EVENT, handleChange as EventListener);
187
+ };
188
+ }, []);
189
+
190
+ const updatePackageManager = (pm: PackageManager) => {
191
+ setPackageManager(pm);
192
+ setStoredPackageManager(pm);
193
+ };
194
+
195
+ return [packageManager, updatePackageManager];
196
+ }
197
+
198
+ // =============================================================================
199
+ // Utility Functions
200
+ // =============================================================================
201
+
202
+ /**
203
+ * Detects if a single line is an npm/npx command that can be transformed
204
+ */
205
+ function isNpmLine(line: string): boolean {
206
+ const trimmed = line.trim();
207
+ return /^(npm\s+(install|i|add|run|exec|create|init|ci|remove|uninstall|rm)|npx\s+)/.test(
208
+ trimmed,
209
+ );
210
+ }
211
+
212
+ /**
213
+ * Detects if the code contains any package manager commands that can be transformed
214
+ */
215
+ function isPackageManagerCommand(code: string): boolean {
216
+ const lines = code.split('\n');
217
+ return lines.some((line) => isNpmLine(line));
218
+ }
219
+
220
+ /**
221
+ * Transforms a single npm/npx command line to the equivalent for other package managers
222
+ */
223
+ function transformSingleLine(line: string, targetPm: PackageManager): string {
224
+ const trimmed = line.trim();
225
+
226
+ if (targetPm === 'npm') return line;
227
+
228
+ // Handle npx commands
229
+ if (trimmed.startsWith('npx ')) {
230
+ const rest = trimmed.slice(4);
231
+ switch (targetPm) {
232
+ case 'pnpm':
233
+ return `pnpm dlx ${rest}`;
234
+ case 'yarn':
235
+ return `yarn dlx ${rest}`;
236
+ case 'bun':
237
+ return `bunx ${rest}`;
238
+ }
239
+ }
240
+
241
+ // Handle npm create / npm init
242
+ if (/^npm\s+(create|init)\s+/.test(trimmed)) {
243
+ const match = trimmed.match(/^npm\s+(create|init)\s+(.+)$/);
244
+ if (match) {
245
+ const args = match[2];
246
+ switch (targetPm) {
247
+ case 'pnpm':
248
+ return `pnpm create ${args}`;
249
+ case 'yarn':
250
+ return `yarn create ${args}`;
251
+ case 'bun':
252
+ return `bun create ${args}`;
253
+ }
254
+ }
255
+ }
256
+
257
+ // Handle npm install / npm i / npm add (with packages)
258
+ if (/^npm\s+(install|i|add)\s+/.test(trimmed)) {
259
+ const match = trimmed.match(/^npm\s+(install|i|add)\s+(.+)$/);
260
+ if (match) {
261
+ const packages = match[2] ?? '';
262
+ // Handle flags
263
+ const devFlag = packages.includes('-D') || packages.includes('--save-dev');
264
+ const cleanPackages = packages
265
+ .replace(/\s*-D\s*/, ' ')
266
+ .replace(/\s*--save-dev\s*/, ' ')
267
+ .trim();
268
+
269
+ switch (targetPm) {
270
+ case 'pnpm':
271
+ return devFlag ? `pnpm add -D ${cleanPackages}` : `pnpm add ${cleanPackages}`;
272
+ case 'yarn':
273
+ return devFlag ? `yarn add -D ${cleanPackages}` : `yarn add ${cleanPackages}`;
274
+ case 'bun':
275
+ return devFlag ? `bun add -d ${cleanPackages}` : `bun add ${cleanPackages}`;
276
+ }
277
+ }
278
+ }
279
+
280
+ // Handle npm install (without packages - install from package.json)
281
+ if (/^npm\s+(install|i|ci)$/.test(trimmed)) {
282
+ switch (targetPm) {
283
+ case 'pnpm':
284
+ return 'pnpm install';
285
+ case 'yarn':
286
+ return 'yarn';
287
+ case 'bun':
288
+ return 'bun install';
289
+ }
290
+ }
291
+
292
+ // Handle npm run <script>
293
+ if (/^npm\s+run\s+/.test(trimmed)) {
294
+ const match = trimmed.match(/^npm\s+run\s+(.+)$/);
295
+ if (match) {
296
+ const script = match[1];
297
+ switch (targetPm) {
298
+ case 'pnpm':
299
+ return `pnpm run ${script}`;
300
+ case 'yarn':
301
+ return `yarn ${script}`;
302
+ case 'bun':
303
+ return `bun run ${script}`;
304
+ }
305
+ }
306
+ }
307
+
308
+ // Handle npm remove / npm uninstall / npm rm
309
+ if (/^npm\s+(remove|uninstall|rm)\s+/.test(trimmed)) {
310
+ const match = trimmed.match(/^npm\s+(remove|uninstall|rm)\s+(.+)$/);
311
+ if (match) {
312
+ const packages = match[2];
313
+ switch (targetPm) {
314
+ case 'pnpm':
315
+ return `pnpm remove ${packages}`;
316
+ case 'yarn':
317
+ return `yarn remove ${packages}`;
318
+ case 'bun':
319
+ return `bun remove ${packages}`;
320
+ }
321
+ }
322
+ }
323
+
324
+ // Handle npm exec
325
+ if (/^npm\s+exec\s+/.test(trimmed)) {
326
+ const match = trimmed.match(/^npm\s+exec\s+(.+)$/);
327
+ if (match) {
328
+ const rest = match[1];
329
+ switch (targetPm) {
330
+ case 'pnpm':
331
+ return `pnpm exec ${rest}`;
332
+ case 'yarn':
333
+ return `yarn exec ${rest}`;
334
+ case 'bun':
335
+ return `bun x ${rest}`;
336
+ }
337
+ }
338
+ }
339
+
340
+ return line;
341
+ }
342
+
343
+ /**
344
+ * Transforms all npm/npx commands in a code block to the target package manager.
345
+ * Leaves non-npm lines unchanged.
346
+ */
347
+ function transformCommand(code: string, targetPm: PackageManager): string {
348
+ if (targetPm === 'npm') return code;
349
+
350
+ const lines = code.split('\n');
351
+ const transformedLines = lines.map((line) => {
352
+ if (isNpmLine(line)) {
353
+ return transformSingleLine(line, targetPm);
354
+ }
355
+ return line;
356
+ });
357
+
358
+ return transformedLines.join('\n');
359
+ }
360
+
361
+ /**
362
+ * Languages where Shiki transformers work (have // or # comment syntax)
363
+ */
364
+ const SHIKI_TRANSFORMER_LANGUAGES = new Set([
365
+ 'javascript',
366
+ 'js',
367
+ 'typescript',
368
+ 'ts',
369
+ 'tsx',
370
+ 'jsx',
371
+ 'java',
372
+ 'c',
373
+ 'cpp',
374
+ 'csharp',
375
+ 'cs',
376
+ 'go',
377
+ 'rust',
378
+ 'swift',
379
+ 'kotlin',
380
+ 'scala',
381
+ 'php',
382
+ 'ruby',
383
+ 'python',
384
+ 'py',
385
+ 'bash',
386
+ 'shell',
387
+ 'sh',
388
+ 'zsh',
389
+ 'yaml',
390
+ 'yml',
391
+ ]);
392
+
393
+ /**
394
+ * Process code to extract filename directive and strip Shiki notations for unsupported languages.
395
+ * Supports: // filename: path/to/file.ts (must be first line)
396
+ *
397
+ * Note: Line highlighting uses Shiki's native notation:
398
+ * - // [!code highlight] for JS/TS (at end of line)
399
+ * - # [!code highlight] for bash/shell (at end of line)
400
+ * - // [!code ++] and // [!code --] for diff
401
+ * - // [!code focus] for focus mode
402
+ *
403
+ * For languages like 'text' that don't support comments, these notations are stripped.
404
+ */
405
+ function processCode(
406
+ code: string,
407
+ language?: string,
408
+ ): {
409
+ cleanCode: string;
410
+ extractedFilename?: string;
411
+ } {
412
+ const lines = code.split('\n');
413
+ const cleanLines: string[] = [];
414
+ let extractedFilename: string | undefined;
415
+
416
+ // Check if we need to strip Shiki notations (for languages without comment support)
417
+ const shouldStripNotations = !SHIKI_TRANSFORMER_LANGUAGES.has(language?.toLowerCase() || '');
418
+
419
+ for (let i = 0; i < lines.length; i++) {
420
+ let line = lines[i] ?? '';
421
+ const trimmedLine = line.trim();
422
+
423
+ // Check for filename directive (must be first non-empty line)
424
+ if (i === 0 || (cleanLines.length === 0 && !extractedFilename)) {
425
+ const filenameMatch = trimmedLine.match(/^\/\/\s*filename:\s*(.+)$/);
426
+ if (filenameMatch) {
427
+ extractedFilename = filenameMatch[1]?.trim();
428
+ continue; // Don't include this directive line
429
+ }
430
+ }
431
+
432
+ // Strip Shiki notations for unsupported languages
433
+ if (shouldStripNotations) {
434
+ // Remove // [!code ...] notations
435
+ line = line.replace(/\s*\/\/\s*\[!code\s+[^\]]+\]\s*$/, '');
436
+ // Remove # [!code ...] notations
437
+ line = line.replace(/\s*#\s*\[!code\s+[^\]]+\]\s*$/, '');
438
+ // Skip lines that are only the notation (e.g., standalone "// [!code highlight]")
439
+ if (trimmedLine.match(/^(\/\/|#)\s*\[!code\s+[^\]]+\]\s*$/)) {
440
+ continue;
441
+ }
442
+ }
443
+
444
+ // Add line to clean output
445
+ cleanLines.push(line);
446
+ }
447
+
448
+ return {
449
+ cleanCode: cleanLines.join('\n'),
450
+ extractedFilename,
451
+ };
452
+ }
453
+
454
+ // =============================================================================
455
+ // Shiki Highlighting
456
+ // =============================================================================
457
+
458
+ /**
459
+ * Highlight code using Shiki with all transformers.
460
+ * Uses Shiki's native notation for highlighting:
461
+ * - // [!code highlight] - highlight a line (use language-appropriate comment)
462
+ * - // [!code ++] / // [!code --] - diff highlighting
463
+ * - // [!code focus] - focus mode (blur other lines)
464
+ * - // [!code word:myVar] - highlight specific word
465
+ * - // [!code error] / // [!code warning] - error levels
466
+ */
467
+ async function highlightCode(code: string, language: SupportedLanguage): Promise<string> {
468
+ const cacheKey = `${language}:${code}`;
469
+ const cached = highlightedCodeCache.get(cacheKey);
470
+ if (cached) return cached;
471
+
472
+ const highlighted = getHighlighter().then((highlighter) =>
473
+ highlighter.codeToHtml(code, {
474
+ lang: language,
475
+ themes: {
476
+ light: 'github-light',
477
+ dark: 'github-dark-default',
478
+ },
479
+ transformers: [
480
+ transformerNotationDiff({ matchAlgorithm: 'v3' }),
481
+ transformerNotationHighlight({ matchAlgorithm: 'v3' }),
482
+ transformerNotationWordHighlight({ matchAlgorithm: 'v3' }),
483
+ transformerNotationFocus({ matchAlgorithm: 'v3' }),
484
+ transformerNotationErrorLevel({ matchAlgorithm: 'v3' }),
485
+ ],
486
+ }),
487
+ );
488
+ highlightedCodeCache.set(cacheKey, highlighted);
489
+ return highlighted;
490
+ }
491
+
492
+ /**
493
+ * Language aliases that map to Shiki's bundled language names
494
+ */
495
+ const LANGUAGE_ALIASES: Record<string, SupportedLanguage> = {
496
+ env: 'dotenv',
497
+ js: 'javascript',
498
+ ts: 'typescript',
499
+ sh: 'bash',
500
+ shell: 'bash',
501
+ yml: 'yaml',
502
+ py: 'python',
503
+ md: 'markdown',
504
+ plaintext: 'ini',
505
+ text: 'ini',
506
+ chroma: 'ini',
507
+ };
508
+
509
+ /**
510
+ * Normalize language identifier for Shiki/BundledLanguage.
511
+ * Falls back to 'ini' for unsupported languages (minimal highlighting).
512
+ */
513
+ function normalizeLanguage(lang?: string): SupportedLanguage {
514
+ const normalized = lang?.toLowerCase() || 'ini';
515
+
516
+ // Check for aliases first
517
+ const alias = LANGUAGE_ALIASES[normalized];
518
+ if (alias) {
519
+ return alias;
520
+ }
521
+
522
+ // Check if the language is one of the explicitly bundled grammars.
523
+ if (normalized in languageLoaders) {
524
+ return normalized as SupportedLanguage;
525
+ }
526
+
527
+ // Fallback to 'ini' for unsupported languages (has minimal highlighting)
528
+ return 'ini';
529
+ }
530
+
531
+ /**
532
+ * Escape HTML special characters for safe display (fallback)
533
+ */
534
+ function escapeHtml(text: string): string {
535
+ return text
536
+ .replace(/&/g, '&amp;')
537
+ .replace(/</g, '&lt;')
538
+ .replace(/>/g, '&gt;')
539
+ .replace(/"/g, '&quot;')
540
+ .replace(/'/g, '&#039;');
541
+ }
542
+
543
+ // =============================================================================
544
+ // File Type Icons
545
+ // =============================================================================
546
+
547
+ /**
548
+ * Brand glyphs for common file extensions, shown next to the filename in the
549
+ * header. Path data is inlined from Simple Icons (CC0) so the package takes no
550
+ * icon dependency; glyphs render monochrome via currentColor and inherit the
551
+ * muted header foreground. Unknown extensions fall back to the generic
552
+ * lucide FileIcon.
553
+ */
554
+ interface FileTypeIconDef {
555
+ /** Brand name of the glyph */
556
+ title: string;
557
+ /** SVG path data on a 24x24 viewBox */
558
+ path: string;
559
+ }
560
+
561
+ const typescriptIcon: FileTypeIconDef = {
562
+ title: 'TypeScript',
563
+ path: 'M1.125 0C.502 0 0 .502 0 1.125v21.75C0 23.498.502 24 1.125 24h21.75c.623 0 1.125-.502 1.125-1.125V1.125C24 .502 23.498 0 22.875 0zm17.363 9.75c.612 0 1.154.037 1.627.111a6.38 6.38 0 0 1 1.306.34v2.458a3.95 3.95 0 0 0-.643-.361 5.093 5.093 0 0 0-.717-.26 5.453 5.453 0 0 0-1.426-.2c-.3 0-.573.028-.819.086a2.1 2.1 0 0 0-.623.242c-.17.104-.3.229-.393.374a.888.888 0 0 0-.14.49c0 .196.053.373.156.529.104.156.252.304.443.444s.423.276.696.41c.273.135.582.274.926.416.47.197.892.407 1.266.628.374.222.695.473.963.753.268.279.472.598.614.957.142.359.214.776.214 1.253 0 .657-.125 1.21-.373 1.656a3.033 3.033 0 0 1-1.012 1.085 4.38 4.38 0 0 1-1.487.596c-.566.12-1.163.18-1.79.18a9.916 9.916 0 0 1-1.84-.164 5.544 5.544 0 0 1-1.512-.493v-2.63a5.033 5.033 0 0 0 3.237 1.2c.333 0 .624-.03.872-.09.249-.06.456-.144.623-.25.166-.108.29-.234.373-.38a1.023 1.023 0 0 0-.074-1.089 2.12 2.12 0 0 0-.537-.5 5.597 5.597 0 0 0-.807-.444 27.72 27.72 0 0 0-1.007-.436c-.918-.383-1.602-.852-2.053-1.405-.45-.553-.676-1.222-.676-2.005 0-.614.123-1.141.369-1.582.246-.441.58-.804 1.004-1.089a4.494 4.494 0 0 1 1.47-.629 7.536 7.536 0 0 1 1.77-.201zm-15.113.188h9.563v2.166H9.506v9.646H6.789v-9.646H3.375z',
564
+ };
565
+
566
+ const reactIcon: FileTypeIconDef = {
567
+ title: 'React',
568
+ path: 'M14.23 12.004a2.236 2.236 0 0 1-2.235 2.236 2.236 2.236 0 0 1-2.236-2.236 2.236 2.236 0 0 1 2.235-2.236 2.236 2.236 0 0 1 2.236 2.236zm2.648-10.69c-1.346 0-3.107.96-4.888 2.622-1.78-1.653-3.542-2.602-4.887-2.602-.41 0-.783.093-1.106.278-1.375.793-1.683 3.264-.973 6.365C1.98 8.917 0 10.42 0 12.004c0 1.59 1.99 3.097 5.043 4.03-.704 3.113-.39 5.588.988 6.38.32.187.69.275 1.102.275 1.345 0 3.107-.96 4.888-2.624 1.78 1.654 3.542 2.603 4.887 2.603.41 0 .783-.09 1.106-.275 1.374-.792 1.683-3.263.973-6.365C22.02 15.096 24 13.59 24 12.004c0-1.59-1.99-3.097-5.043-4.032.704-3.11.39-5.587-.988-6.38-.318-.184-.688-.277-1.092-.278zm-.005 1.09v.006c.225 0 .406.044.558.127.666.382.955 1.835.73 3.704-.054.46-.142.945-.25 1.44-.96-.236-2.006-.417-3.107-.534-.66-.905-1.345-1.727-2.035-2.447 1.592-1.48 3.087-2.292 4.105-2.295zm-9.77.02c1.012 0 2.514.808 4.11 2.28-.686.72-1.37 1.537-2.02 2.442-1.107.117-2.154.298-3.113.538-.112-.49-.195-.964-.254-1.42-.23-1.868.054-3.32.714-3.707.19-.09.4-.127.563-.132zm4.882 3.05c.455.468.91.992 1.36 1.564-.44-.02-.89-.034-1.345-.034-.46 0-.915.01-1.36.034.44-.572.895-1.096 1.345-1.565zM12 8.1c.74 0 1.477.034 2.202.093.406.582.802 1.203 1.183 1.86.372.64.71 1.29 1.018 1.946-.308.655-.646 1.31-1.013 1.95-.38.66-.773 1.288-1.18 1.87-.728.063-1.466.098-2.21.098-.74 0-1.477-.035-2.202-.093-.406-.582-.802-1.204-1.183-1.86-.372-.64-.71-1.29-1.018-1.946.303-.657.646-1.313 1.013-1.954.38-.66.773-1.286 1.18-1.868.728-.064 1.466-.098 2.21-.098zm-3.635.254c-.24.377-.48.763-.704 1.16-.225.39-.435.782-.635 1.174-.265-.656-.49-1.31-.676-1.947.64-.15 1.315-.283 2.015-.386zm7.26 0c.695.103 1.365.23 2.006.387-.18.632-.405 1.282-.66 1.933-.2-.39-.41-.783-.64-1.174-.225-.392-.465-.774-.705-1.146zm3.063.675c.484.15.944.317 1.375.498 1.732.74 2.852 1.708 2.852 2.476-.005.768-1.125 1.74-2.857 2.475-.42.18-.88.342-1.355.493-.28-.958-.646-1.956-1.1-2.98.45-1.017.81-2.01 1.085-2.964zm-13.395.004c.278.96.645 1.957 1.1 2.98-.45 1.017-.812 2.01-1.086 2.964-.484-.15-.944-.318-1.37-.5-1.732-.737-2.852-1.706-2.852-2.474 0-.768 1.12-1.742 2.852-2.476.42-.18.88-.342 1.356-.494zm11.678 4.28c.265.657.49 1.312.676 1.948-.64.157-1.316.29-2.016.39.24-.375.48-.762.705-1.158.225-.39.435-.788.636-1.18zm-9.945.02c.2.392.41.783.64 1.175.23.39.465.772.705 1.143-.695-.102-1.365-.23-2.006-.386.18-.63.406-1.282.66-1.933zM17.92 16.32c.112.493.2.968.254 1.423.23 1.868-.054 3.32-.714 3.708-.147.09-.338.128-.563.128-1.012 0-2.514-.807-4.11-2.28.686-.72 1.37-1.536 2.02-2.44 1.107-.118 2.154-.3 3.113-.54zm-11.83.01c.96.234 2.006.415 3.107.532.66.905 1.345 1.727 2.035 2.446-1.595 1.483-3.092 2.295-4.11 2.295-.22-.005-.406-.05-.553-.132-.666-.38-.955-1.834-.73-3.703.054-.46.142-.944.25-1.438zm4.56.64c.44.02.89.034 1.345.034.46 0 .915-.01 1.36-.034-.44.572-.895 1.095-1.345 1.565-.455-.47-.91-.993-1.36-1.565z',
569
+ };
570
+
571
+ const javascriptIcon: FileTypeIconDef = {
572
+ title: 'JavaScript',
573
+ path: 'M0 0h24v24H0V0zm22.034 18.276c-.175-1.095-.888-2.015-3.003-2.873-.736-.345-1.554-.585-1.797-1.14-.091-.33-.105-.51-.046-.705.15-.646.915-.84 1.515-.66.39.12.75.42.976.9 1.034-.676 1.034-.676 1.755-1.125-.27-.42-.404-.601-.586-.78-.63-.705-1.469-1.065-2.834-1.034l-.705.089c-.676.165-1.32.525-1.71 1.005-1.14 1.291-.811 3.541.569 4.471 1.365 1.02 3.361 1.244 3.616 2.205.24 1.17-.87 1.545-1.966 1.41-.811-.18-1.26-.586-1.755-1.336l-1.83 1.051c.21.48.45.689.81 1.109 1.74 1.756 6.09 1.666 6.871-1.004.029-.09.24-.705.074-1.65l.046.067zm-8.983-7.245h-2.248c0 1.938-.009 3.864-.009 5.805 0 1.232.063 2.363-.138 2.711-.33.689-1.18.601-1.566.48-.396-.196-.597-.466-.83-.855-.063-.105-.11-.196-.127-.196l-1.825 1.125c.305.63.75 1.172 1.324 1.517.855.51 2.004.675 3.207.405.783-.226 1.458-.691 1.811-1.411.51-.93.402-2.07.397-3.346.012-2.054 0-4.109 0-6.179l.004-.056z',
574
+ };
575
+
576
+ const jsonIcon: FileTypeIconDef = {
577
+ title: 'JSON',
578
+ path: 'M12.043 23.968c.479-.004.953-.029 1.426-.094a11.805 11.805 0 003.146-.863 12.404 12.404 0 003.793-2.542 11.977 11.977 0 002.44-3.427 11.794 11.794 0 001.02-3.476c.149-1.16.135-2.346-.045-3.499a11.96 11.96 0 00-.793-2.788 11.197 11.197 0 00-.854-1.617c-1.168-1.837-2.861-3.314-4.81-4.3a12.835 12.835 0 00-2.172-.87h-.005c.119.063.24.132.345.201.12.074.239.146.351.225a8.93 8.93 0 011.559 1.33c1.063 1.145 1.797 2.548 2.218 4.041.284.982.434 1.998.495 3.017.044.743.044 1.491-.047 2.229-.149 1.27-.554 2.51-1.228 3.596a7.475 7.475 0 01-1.903 2.084c-1.244.928-2.877 1.482-4.436 1.114a3.916 3.916 0 01-.748-.258 4.692 4.692 0 01-.779-.45 6.08 6.08 0 01-1.244-1.105 6.507 6.507 0 01-1.049-1.747 7.366 7.366 0 01-.494-2.54c-.03-1.273.225-2.553.854-3.67a6.43 6.43 0 011.663-1.918c.225-.178.464-.333.704-.479l.016-.007a5.121 5.121 0 00-1.441-.12 4.963 4.963 0 00-1.228.24c-.359.12-.704.27-1.019.45a6.146 6.146 0 00-.733.494c-.211.18-.42.36-.615.555-1.123 1.153-1.768 2.682-2.022 4.256-.15.973-.15 1.96-.091 2.95.105 1.395.391 2.787.945 4.062a8.518 8.518 0 001.348 2.173 8.14 8.14 0 003.132 2.23 7.934 7.934 0 002.113.54c.074.015.149.015.209.015zm-2.934-.398a4.102 4.102 0 01-.45-.228 8.5 8.5 0 01-2.038-1.534c-1.094-1.137-1.827-2.566-2.247-4.08a15.184 15.184 0 01-.495-3.172 12.14 12.14 0 01.046-2.082c.135-1.257.495-2.501 1.124-3.58a6.889 6.889 0 011.783-2.053 6.23 6.23 0 011.633-.9 5.363 5.363 0 013.522-.045c.029 0 .029 0 .045.03.015.015.045.015.06.03.045.016.104.045.165.074.239.12.479.271.704.42a6.294 6.294 0 012.097 2.502c.42.914.615 1.934.631 2.938.014 1.079-.18 2.157-.645 3.146a6.42 6.42 0 01-2.638 2.832c.09.03.18.045.271.075.225.044.449.074.688.074 1.468.045 2.892-.66 3.94-1.647.195-.18.375-.375.54-.585.225-.27.435-.54.614-.823.239-.375.435-.75.614-1.154a8.112 8.112 0 00.509-1.664c.196-1.004.211-2.022.149-3.026-.135-2.022-.673-4.045-1.842-5.724a9.054 9.054 0 00-.555-.719 9.868 9.868 0 00-1.063-1.034 8.477 8.477 0 00-1.363-.915 9.927 9.927 0 00-1.692-.598l-.3-.06c-.209-.03-.42-.044-.634-.06a8.453 8.453 0 00-1.015.016c-.704.045-1.412.16-2.112.337C5.799 1.227 2.863 3.566 1.3 6.67A11.834 11.834 0 00.238 9.801a11.81 11.81 0 00-.104 3.775c.12 1.02.374 2.023.778 2.977.227.57.511 1.124.825 1.648 1.094 1.783 2.683 3.236 4.51 4.24.688.39 1.408.69 2.157.944.226.074.45.15.689.21z',
579
+ };
580
+
581
+ const htmlIcon: FileTypeIconDef = {
582
+ title: 'HTML5',
583
+ path: 'M1.5 0h21l-1.91 21.563L11.977 24l-8.564-2.438L1.5 0zm7.031 9.75l-.232-2.718 10.059.003.23-2.622L5.412 4.41l.698 8.01h9.126l-.326 3.426-2.91.804-2.955-.81-.188-2.11H6.248l.33 4.171L12 19.351l5.379-1.443.744-8.157H8.531z',
584
+ };
585
+
586
+ const cssIcon: FileTypeIconDef = {
587
+ title: 'CSS',
588
+ path: 'M0 0v20.16A3.84 3.84 0 0 0 3.84 24h16.32A3.84 3.84 0 0 0 24 20.16V3.84A3.84 3.84 0 0 0 20.16 0Zm14.256 13.08c1.56 0 2.28 1.08 2.304 2.64h-1.608c.024-.288-.048-.6-.144-.84-.096-.192-.288-.264-.552-.264-.456 0-.696.264-.696.84-.024.576.288.888.768 1.08.72.288 1.608.744 1.92 1.296q.432.648.432 1.656c0 1.608-.912 2.592-2.496 2.592-1.656 0-2.4-1.032-2.424-2.688h1.68c0 .792.264 1.176.792 1.176.264 0 .456-.072.552-.24.192-.312.24-1.176-.048-1.512-.312-.408-.912-.6-1.32-.816q-.828-.396-1.224-.936c-.24-.36-.36-.888-.36-1.536 0-1.44.936-2.472 2.424-2.448m5.4 0c1.584 0 2.304 1.08 2.328 2.64h-1.608c0-.288-.048-.6-.168-.84-.096-.192-.264-.264-.528-.264-.48 0-.72.264-.72.84s.288.888.792 1.08c.696.288 1.608.744 1.92 1.296.264.432.408.984.408 1.656.024 1.608-.888 2.592-2.472 2.592-1.68 0-2.424-1.056-2.448-2.688h1.68c0 .744.264 1.176.792 1.176.264 0 .456-.072.552-.24.216-.312.264-1.176-.048-1.512-.288-.408-.888-.6-1.32-.816-.552-.264-.96-.576-1.2-.936s-.36-.888-.36-1.536c-.024-1.44.912-2.472 2.4-2.448m-11.031.018c.711-.006 1.419.198 1.839.63.432.432.672 1.128.648 1.992H9.336c.024-.456-.096-.792-.432-.96-.312-.144-.768-.048-.888.24-.12.264-.192.576-.168.864v3.504c0 .744.264 1.128.768 1.128a.65.65 0 0 0 .552-.264c.168-.24.192-.552.168-.84h1.776c.096 1.632-.984 2.712-2.568 2.688-1.536 0-2.496-.864-2.472-2.472v-4.032c0-.816.24-1.44.696-1.848.432-.408 1.146-.624 1.857-.63',
589
+ };
590
+
591
+ const markdownIcon: FileTypeIconDef = {
592
+ title: 'Markdown',
593
+ path: 'M22.27 19.385H1.73A1.73 1.73 0 010 17.655V6.345a1.73 1.73 0 011.73-1.73h20.54A1.73 1.73 0 0124 6.345v11.308a1.73 1.73 0 01-1.73 1.731zM5.769 15.923v-4.5l2.308 2.885 2.307-2.885v4.5h2.308V8.078h-2.308l-2.307 2.885-2.308-2.885H3.46v7.847zM21.232 12h-2.309V8.077h-2.307V12h-2.308l3.461 4.039z',
594
+ };
595
+
596
+ const mdxIcon: FileTypeIconDef = {
597
+ title: 'MDX',
598
+ path: 'M.79 7.12h22.42c.436 0 .79.355.79.792v8.176c0 .436-.354.79-.79.79H.79a.79.79 0 0 1-.79-.79V7.912a.79.79 0 0 1 .79-.791V7.12Zm2.507 7.605v-3.122l1.89 1.89L7.12 11.56v3.122h1.055v-5.67l-2.99 2.99L2.24 9.056v5.67h1.055v-.001Zm8.44-1.845-1.474-1.473-.746.746 2.747 2.747 2.745-2.747-.746-.746-1.473 1.473v-4h-1.054v4Zm10.041.987-2.175-2.175 2.22-2.22-.746-.746-2.22 2.22-2.22-2.22-.747.746 2.22 2.22-2.176 2.177.746.746 2.177-2.177 2.176 2.175.745-.746Z',
599
+ };
600
+
601
+ const yamlIcon: FileTypeIconDef = {
602
+ title: 'YAML',
603
+ path: 'm0 .97 4.111 6.453v4.09h2.638v-4.09L11.053.969H8.214L5.58 5.125 2.965.969Zm12.093.024-4.47 10.544h2.114l.97-2.345h4.775l.804 2.345h2.26L14.255.994Zm1.133 2.225 1.463 3.87h-3.096zm3.06 9.475v10.29H24v-2.199h-5.454v-8.091zm-12.175.002v10.335h2.217v-7.129l2.32 4.792h1.746l2.4-4.96v7.295h2.127V12.696h-2.904L9.44 17.37l-2.455-4.674Z',
604
+ };
605
+
606
+ const graphqlIcon: FileTypeIconDef = {
607
+ title: 'GraphQL',
608
+ path: 'M12.002 0a2.138 2.138 0 1 0 0 4.277 2.138 2.138 0 1 0 0-4.277zm8.54 4.931a2.138 2.138 0 1 0 0 4.277 2.138 2.138 0 1 0 0-4.277zm0 9.862a2.138 2.138 0 1 0 0 4.277 2.138 2.138 0 1 0 0-4.277zm-8.54 4.931a2.138 2.138 0 1 0 0 4.276 2.138 2.138 0 1 0 0-4.276zm-8.542-4.93a2.138 2.138 0 1 0 0 4.276 2.138 2.138 0 1 0 0-4.277zm0-9.863a2.138 2.138 0 1 0 0 4.277 2.138 2.138 0 1 0 0-4.277zm8.542-3.378L2.953 6.777v10.448l9.049 5.224 9.047-5.224V6.777zm0 1.601 7.66 13.27H4.34zm-1.387.371L3.97 15.037V7.363zm2.774 0 6.646 3.838v7.674zM5.355 17.44h13.293l-6.646 3.836z',
609
+ };
610
+
611
+ const bashIcon: FileTypeIconDef = {
612
+ title: 'Bash',
613
+ path: 'M21.038,4.9l-7.577-4.498C13.009,0.134,12.505,0,12,0c-0.505,0-1.009,0.134-1.462,0.403L2.961,4.9 C2.057,5.437,1.5,6.429,1.5,7.503v8.995c0,1.073,0.557,2.066,1.462,2.603l7.577,4.497C10.991,23.866,11.495,24,12,24 c0.505,0,1.009-0.134,1.461-0.402l7.577-4.497c0.904-0.537,1.462-1.529,1.462-2.603V7.503C22.5,6.429,21.943,5.437,21.038,4.9z M15.17,18.946l0.013,0.646c0.001,0.078-0.05,0.167-0.111,0.198l-0.383,0.22c-0.061,0.031-0.111-0.007-0.112-0.085L14.57,19.29 c-0.328,0.136-0.66,0.169-0.872,0.084c-0.04-0.016-0.057-0.075-0.041-0.142l0.139-0.584c0.011-0.046,0.036-0.092,0.069-0.121 c0.012-0.011,0.024-0.02,0.036-0.026c0.022-0.011,0.043-0.014,0.062-0.006c0.229,0.077,0.521,0.041,0.802-0.101 c0.357-0.181,0.596-0.545,0.592-0.907c-0.003-0.328-0.181-0.465-0.613-0.468c-0.55,0.001-1.064-0.107-1.072-0.917 c-0.007-0.667,0.34-1.361,0.889-1.8l-0.007-0.652c-0.001-0.08,0.048-0.168,0.111-0.2l0.37-0.236 c0.061-0.031,0.111,0.007,0.112,0.087l0.006,0.653c0.273-0.109,0.511-0.138,0.726-0.088c0.047,0.012,0.067,0.076,0.048,0.151 l-0.144,0.578c-0.011,0.044-0.036,0.088-0.065,0.116c-0.012,0.012-0.025,0.021-0.038,0.028c-0.019,0.01-0.038,0.013-0.057,0.009 c-0.098-0.022-0.332-0.073-0.699,0.113c-0.385,0.195-0.52,0.53-0.517,0.778c0.003,0.297,0.155,0.387,0.681,0.396 c0.7,0.012,1.003,0.318,1.01,1.023C16.105,17.747,15.736,18.491,15.17,18.946z M19.143,17.859c0,0.06-0.008,0.116-0.058,0.145 l-1.916,1.164c-0.05,0.029-0.09,0.004-0.09-0.056v-0.494c0-0.06,0.037-0.093,0.087-0.122l1.887-1.129 c0.05-0.029,0.09-0.004,0.09,0.056V17.859z M20.459,6.797l-7.168,4.427c-0.894,0.523-1.553,1.109-1.553,2.187v8.833 c0,0.645,0.26,1.063,0.66,1.184c-0.131,0.023-0.264,0.039-0.398,0.039c-0.42,0-0.833-0.114-1.197-0.33L3.226,18.64 c-0.741-0.44-1.201-1.261-1.201-2.142V7.503c0-0.881,0.46-1.702,1.201-2.142l7.577-4.498c0.363-0.216,0.777-0.33,1.197-0.33 c0.419,0,0.833,0.114,1.197,0.33l7.577,4.498c0.624,0.371,1.046,1.013,1.164,1.732C21.686,6.557,21.12,6.411,20.459,6.797z',
614
+ };
615
+
616
+ const dockerIcon: FileTypeIconDef = {
617
+ title: 'Docker',
618
+ path: 'M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z',
619
+ };
620
+
621
+ const FILE_TYPE_ICONS: Record<string, FileTypeIconDef> = {
622
+ ts: typescriptIcon,
623
+ mts: typescriptIcon,
624
+ cts: typescriptIcon,
625
+ tsx: reactIcon,
626
+ jsx: reactIcon,
627
+ js: javascriptIcon,
628
+ mjs: javascriptIcon,
629
+ cjs: javascriptIcon,
630
+ json: jsonIcon,
631
+ jsonc: jsonIcon,
632
+ html: htmlIcon,
633
+ css: cssIcon,
634
+ md: markdownIcon,
635
+ mdx: mdxIcon,
636
+ yml: yamlIcon,
637
+ yaml: yamlIcon,
638
+ graphql: graphqlIcon,
639
+ gql: graphqlIcon,
640
+ sh: bashIcon,
641
+ bash: bashIcon,
642
+ zsh: bashIcon,
643
+ dockerfile: dockerIcon,
644
+ };
645
+
646
+ /**
647
+ * Resolve the brand icon for a filename by its extension. Matching uses the
648
+ * basename, so directory names never interfere. `Dockerfile` matches by full
649
+ * basename since it has no extension. Returns undefined for unknown extensions
650
+ * and dotfiles; the header then falls back to the generic file icon.
651
+ */
652
+ function matchFileTypeIcon(filename: string): FileTypeIconDef | undefined {
653
+ const basename = filename.split('/').pop()?.toLowerCase() ?? '';
654
+ if (basename === 'dockerfile') return FILE_TYPE_ICONS.dockerfile;
655
+ const dotIndex = basename.lastIndexOf('.');
656
+ if (dotIndex <= 0) return undefined;
657
+ return FILE_TYPE_ICONS[basename.slice(dotIndex + 1)];
658
+ }
659
+
660
+ function FileTypeIcon({ icon }: { icon: FileTypeIconDef }) {
661
+ return (
662
+ <svg viewBox="0 0 24 24" className="size-3.5 shrink-0 fill-current" aria-hidden="true">
663
+ <path d={icon.path} />
664
+ </svg>
665
+ );
666
+ }
667
+
668
+ // =============================================================================
669
+ // Style Constants
670
+ // =============================================================================
671
+
672
+ const darkModeClassNames = cn(
673
+ 'dark:[&_.shiki]:!text-[var(--shiki-dark)]',
674
+ 'dark:[&_.shiki]:![font-style:var(--shiki-dark-font-style)]',
675
+ 'dark:[&_.shiki]:![font-weight:var(--shiki-dark-font-weight)]',
676
+ 'dark:[&_.shiki]:![text-decoration:var(--shiki-dark-text-decoration)]',
677
+ 'dark:[&_.shiki_span]:!text-[var(--shiki-dark)]',
678
+ 'dark:[&_.shiki_span]:![font-style:var(--shiki-dark-font-style)]',
679
+ 'dark:[&_.shiki_span]:![font-weight:var(--shiki-dark-font-weight)]',
680
+ 'dark:[&_.shiki_span]:![text-decoration:var(--shiki-dark-text-decoration)]',
681
+ );
682
+
683
+ const lineHighlightClassNames = cn(
684
+ '[&_.line.highlighted]:bg-code-highlight',
685
+ '[&_.line.highlighted]:after:bg-code-highlight-accent',
686
+ '[&_.line.highlighted]:after:absolute',
687
+ '[&_.line.highlighted]:after:left-0',
688
+ '[&_.line.highlighted]:after:top-0',
689
+ '[&_.line.highlighted]:after:bottom-0',
690
+ '[&_.line.highlighted]:after:w-0.5',
691
+ );
692
+
693
+ const lineDiffClassNames = cn(
694
+ '[&_.line.diff]:after:absolute',
695
+ '[&_.line.diff]:after:left-0',
696
+ '[&_.line.diff]:after:top-0',
697
+ '[&_.line.diff]:after:bottom-0',
698
+ '[&_.line.diff]:after:w-0.5',
699
+ '[&_.line.diff.add]:bg-code-diff-add',
700
+ '[&_.line.diff.add]:after:bg-code-diff-add-accent',
701
+ '[&_.line.diff.remove]:bg-code-diff-remove',
702
+ '[&_.line.diff.remove]:after:bg-code-diff-remove-accent',
703
+ );
704
+
705
+ const lineFocusedClassNames = cn(
706
+ '[&_code:has(.focused)_.line]:blur-[2px]',
707
+ '[&_code:has(.focused)_.line.focused]:blur-none',
708
+ );
709
+
710
+ const wordHighlightClassNames = cn('[&_.highlighted-word]:bg-code-highlight');
711
+
712
+ // =============================================================================
713
+ // Sub-Components
714
+ // =============================================================================
715
+
716
+ /**
717
+ * Tooltip icon-button used for code-block toolbar actions (and the built-in copy
718
+ * button). Compose these into the `actions` slot of {@link CodeBlock}.
719
+ */
720
+ function CodeBlockAction({ icon, label, onClick, disabled }: CodeBlockActionProps) {
721
+ return (
722
+ <Tooltip>
723
+ <TooltipTrigger
724
+ render={
725
+ <Button
726
+ variant="ghost"
727
+ size="icon"
728
+ onClick={onClick}
729
+ aria-label={label}
730
+ disabled={disabled}
731
+ className={cn('h-7 w-7', disabled && 'cursor-not-allowed opacity-50')}
732
+ />
733
+ }
734
+ >
735
+ {icon}
736
+ </TooltipTrigger>
737
+ <TooltipContent side="top">{label}</TooltipContent>
738
+ </Tooltip>
739
+ );
740
+ }
741
+
742
+ interface CodeBlockToolbarProps {
743
+ onCopy: () => void;
744
+ isCopied: boolean;
745
+ actions?: ReactNode;
746
+ }
747
+
748
+ function CodeBlockToolbar({ onCopy, isCopied, actions }: CodeBlockToolbarProps) {
749
+ return (
750
+ <div className="flex items-center gap-0.5">
751
+ {actions}
752
+ <CodeBlockAction
753
+ icon={
754
+ isCopied ? <CheckIcon className="size-4 text-success" /> : <CopyIcon className="size-4" />
755
+ }
756
+ label={isCopied ? 'Copied!' : 'Copy code'}
757
+ onClick={onCopy}
758
+ />
759
+ </div>
760
+ );
761
+ }
762
+
763
+ interface PackageManagerTabsProps {
764
+ activeManager: PackageManager;
765
+ onSelect: (pm: PackageManager) => void;
766
+ id: string;
767
+ }
768
+
769
+ function PackageManagerTabs({ activeManager, onSelect, id }: PackageManagerTabsProps) {
770
+ return (
771
+ // clip-path instead of overflow-hidden: overflow clips children at the
772
+ // padding box, so the active-tab underline could never cover the bottom
773
+ // border. Clipping at the border box lets it overlay the border row while
774
+ // the corners stay rounded.
775
+ <div className="bg-background border-border inline-flex rounded-md border text-xs [clip-path:inset(0_round_var(--radius-md))]">
776
+ {PACKAGE_MANAGERS.map((pm, index) => (
777
+ <div key={pm} className="flex items-center">
778
+ {index > 0 && <div className="bg-border h-4 w-px" aria-hidden="true" />}
779
+ <Button
780
+ variant="ghost"
781
+ size="sm"
782
+ className={cn(
783
+ 'relative h-auto rounded-none px-3 py-1.5 text-xs font-medium transition-colors',
784
+ activeManager === pm
785
+ ? 'bg-muted text-foreground'
786
+ : 'text-muted-foreground hover:text-foreground hover:bg-muted/50',
787
+ )}
788
+ onClick={() => onSelect(pm)}
789
+ >
790
+ {pm}
791
+ {activeManager === pm && (
792
+ <motion.div
793
+ className="bg-brand absolute inset-x-0 -bottom-px mx-auto h-0.5 w-full"
794
+ layoutId={`codeblock-pm-tab-${id}`}
795
+ initial={false}
796
+ transition={{
797
+ type: 'spring',
798
+ stiffness: 500,
799
+ damping: 30,
800
+ }}
801
+ />
802
+ )}
803
+ </Button>
804
+ </div>
805
+ ))}
806
+ </div>
807
+ );
808
+ }
809
+
810
+ interface CodeBlockHeaderProps {
811
+ filename?: string;
812
+ language?: string;
813
+ isCommand?: boolean;
814
+ showPackageManagerTabs?: boolean;
815
+ activeManager?: PackageManager;
816
+ onSelectManager?: (pm: PackageManager) => void;
817
+ toolbar: ReactNode;
818
+ id: string;
819
+ }
820
+
821
+ function CodeBlockHeader({
822
+ filename,
823
+ language,
824
+ isCommand,
825
+ showPackageManagerTabs,
826
+ activeManager,
827
+ onSelectManager,
828
+ toolbar,
829
+ id,
830
+ }: CodeBlockHeaderProps) {
831
+ // Determine what label to show
832
+ const getLabel = () => {
833
+ if (filename) {
834
+ const fileTypeIcon = matchFileTypeIcon(filename);
835
+ return (
836
+ <div className="text-muted-foreground flex items-center gap-1.5 text-sm">
837
+ {fileTypeIcon ? <FileTypeIcon icon={fileTypeIcon} /> : <FileIcon className="size-3.5" />}
838
+ <span>{filename}</span>
839
+ </div>
840
+ );
841
+ }
842
+
843
+ if (showPackageManagerTabs && activeManager && onSelectManager) {
844
+ return (
845
+ <PackageManagerTabs activeManager={activeManager} onSelect={onSelectManager} id={id} />
846
+ );
847
+ }
848
+
849
+ if (isCommand) {
850
+ return (
851
+ <div className="text-muted-foreground flex items-center gap-1.5 text-sm">
852
+ <TerminalIcon className="size-3.5" />
853
+ <span>Terminal</span>
854
+ </div>
855
+ );
856
+ }
857
+
858
+ // Default: show language or "Code"
859
+ return (
860
+ <div className="text-muted-foreground flex items-center gap-1.5 text-sm">
861
+ <CodeIcon className="size-3.5" />
862
+ <span>{language ? language.charAt(0).toUpperCase() + language.slice(1) : 'Code'}</span>
863
+ </div>
864
+ );
865
+ };
866
+
867
+ return (
868
+ <div className="bg-muted/50 border-border flex items-center justify-between border-b px-3 py-2">
869
+ <div className="flex items-center gap-2">{getLabel()}</div>
870
+ {toolbar}
871
+ </div>
872
+ );
873
+ }
874
+
875
+ interface FadeOverlayProps {
876
+ visible: boolean;
877
+ }
878
+
879
+ function FadeOverlay({ visible }: FadeOverlayProps) {
880
+ return (
881
+ <div
882
+ className={cn(
883
+ 'from-card pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t to-transparent transition-opacity duration-300',
884
+ visible ? 'opacity-100' : 'opacity-0',
885
+ )}
886
+ aria-hidden="true"
887
+ />
888
+ );
889
+ }
890
+
891
+ interface ExpandButtonProps {
892
+ isExpanded: boolean;
893
+ hiddenLineCount: number;
894
+ onToggle: () => void;
895
+ }
896
+
897
+ function ExpandButton({ isExpanded, hiddenLineCount, onToggle }: ExpandButtonProps) {
898
+ return (
899
+ <div className="border-border bg-muted/30 flex items-center justify-center border-t py-2">
900
+ <Button
901
+ variant="ghost"
902
+ size="sm"
903
+ onClick={onToggle}
904
+ className="text-muted-foreground hover:text-foreground gap-1.5 text-xs"
905
+ >
906
+ {isExpanded ? (
907
+ <>
908
+ <ChevronUpIcon className="size-3.5" />
909
+ Show less
910
+ </>
911
+ ) : (
912
+ <>
913
+ <ChevronDownIcon className="size-3.5" />
914
+ Show {hiddenLineCount} more lines
915
+ </>
916
+ )}
917
+ </Button>
918
+ </div>
919
+ );
920
+ }
921
+
922
+ const codeBlockContentClassName = cn(
923
+ 'bg-transparent text-sm',
924
+ '[&_pre]:py-4',
925
+ '[&_.shiki]:!bg-transparent',
926
+ '[&_code]:w-full',
927
+ '[&_code]:grid',
928
+ '[&_code]:overflow-x-auto',
929
+ '[&_code]:bg-transparent',
930
+ '[&_.line]:px-4',
931
+ '[&_.line]:w-full',
932
+ '[&_.line]:relative',
933
+ );
934
+
935
+ // =============================================================================
936
+ // Syntax Highlighted Content Component
937
+ // =============================================================================
938
+
939
+ interface SyntaxHighlightedContentProps {
940
+ code: string;
941
+ language: SupportedLanguage;
942
+ }
943
+
944
+ function SyntaxHighlightedContent({ code, language }: SyntaxHighlightedContentProps) {
945
+ const [html, setHtml] = useState<string | null>(null);
946
+
947
+ useEffect(() => {
948
+ highlightCode(code, language)
949
+ .then(setHtml)
950
+ .catch((error) => {
951
+ console.error('Shiki highlighting failed:', error);
952
+ // Fallback to escaped plain text
953
+ setHtml(`<pre class="shiki"><code>${escapeHtml(code)}</code></pre>`);
954
+ });
955
+ }, [code, language]);
956
+
957
+ if (!html) {
958
+ // Show fallback while loading
959
+ return (
960
+ <pre className="shiki py-4">
961
+ <code className="grid w-full overflow-x-auto bg-transparent">
962
+ {code.split('\n').map((line, i) => (
963
+ <span key={i} className="line relative w-full px-4">
964
+ {line}
965
+ </span>
966
+ ))}
967
+ </code>
968
+ </pre>
969
+ );
970
+ }
971
+
972
+ return <div dangerouslySetInnerHTML={{ __html: html }} />;
973
+ }
974
+
975
+ // =============================================================================
976
+ // Main Component
977
+ // =============================================================================
978
+
979
+ /**
980
+ * Shiki-highlighted code block with a header, copy button, collapse/expand for
981
+ * long snippets, and an optional npm→pnpm/yarn/bun package-manager switcher.
982
+ * Highlighting is lazy (a shared highlighter singleton + per-snippet cache), and
983
+ * line/diff/focus/word decorations use Shiki's `[!code ...]` notations. The DS
984
+ * never toasts. Wire `onCopied`/`onCopyError` to your own feedback — or, when
985
+ * rendering from server components (where function props can't be passed),
986
+ * mount `CopyFeedbackProvider` once and leave the props off.
987
+ */
988
+ export function CodeBlock({
989
+ className,
990
+ language,
991
+ filename,
992
+ children,
993
+ hideHeader = false,
994
+ packageManagerSwitcher = false,
995
+ actions,
996
+ onCopied,
997
+ onCopyError,
998
+ ...props
999
+ }: CodeBlockProps) {
1000
+ const { copied, copy } = useCopy();
1001
+ const copyFeedback = useCopyFeedback();
1002
+ const [activePackageManager, setActivePackageManager] = usePackageManager();
1003
+ const [isExpanded, setIsExpanded] = useState(false);
1004
+ const containerRef = useRef<HTMLDivElement>(null);
1005
+ const instanceId = useId();
1006
+
1007
+ // Process the raw code
1008
+ const rawCode = useMemo(() => {
1009
+ return typeof children === 'string'
1010
+ ? children
1011
+ : Array.isArray(children)
1012
+ ? children.join('')
1013
+ : String(children || '');
1014
+ }, [children]);
1015
+
1016
+ // Process code directives (filename extraction, strip notations for unsupported languages)
1017
+ const { cleanCode, extractedFilename } = useMemo(
1018
+ () => processCode(rawCode, language),
1019
+ [rawCode, language],
1020
+ );
1021
+
1022
+ // Use provided filename or extracted one
1023
+ const displayFilename = filename || extractedFilename;
1024
+
1025
+ // Detect if this is a package manager command (only when the switcher is enabled)
1026
+ const isShellLanguage =
1027
+ language?.toLowerCase() === 'bash' ||
1028
+ language?.toLowerCase() === 'shell' ||
1029
+ language?.toLowerCase() === 'sh';
1030
+ const isCommand = packageManagerSwitcher && isShellLanguage && isPackageManagerCommand(cleanCode);
1031
+
1032
+ // Get the display code (transformed for selected package manager if applicable)
1033
+ const displayCode = useMemo(() => {
1034
+ if (isCommand) {
1035
+ return transformCommand(cleanCode, activePackageManager);
1036
+ }
1037
+ return cleanCode;
1038
+ }, [cleanCode, isCommand, activePackageManager]);
1039
+
1040
+ // Calculate if the code block is collapsible
1041
+ const lineCount = useMemo(() => displayCode.split('\n').length, [displayCode]);
1042
+ const isCollapsible = lineCount > COLLAPSE_THRESHOLD;
1043
+ const hiddenLineCount = lineCount - Math.floor(COLLAPSED_HEIGHT / 24); // Approximate visible lines based on line height
1044
+
1045
+ const handleCopy = async () => {
1046
+ const ok = await copy(displayCode);
1047
+ if (ok) (onCopied ?? copyFeedback.onCopied)?.();
1048
+ else (onCopyError ?? copyFeedback.onCopyError)?.(new Error('Failed to copy to the clipboard'));
1049
+ };
1050
+
1051
+ const toolbar = <CodeBlockToolbar onCopy={handleCopy} isCopied={copied} actions={actions} />;
1052
+
1053
+ const showCollapsed = isCollapsible && !isExpanded;
1054
+
1055
+ return (
1056
+ <div
1057
+ data-slot="code-block"
1058
+ {...props}
1059
+ className={cn(
1060
+ 'bg-card not-prose border-border relative mb-4 w-full contain-inline-size overflow-hidden rounded-md border text-sm lg:text-base',
1061
+ className,
1062
+ )}
1063
+ ref={containerRef}
1064
+ >
1065
+ {!hideHeader && (
1066
+ <CodeBlockHeader
1067
+ filename={displayFilename}
1068
+ language={language}
1069
+ isCommand={isCommand}
1070
+ showPackageManagerTabs={isCommand}
1071
+ activeManager={activePackageManager}
1072
+ onSelectManager={setActivePackageManager}
1073
+ toolbar={toolbar}
1074
+ id={instanceId}
1075
+ />
1076
+ )}
1077
+
1078
+ <div className="relative">
1079
+ <motion.div
1080
+ className="overflow-hidden"
1081
+ initial={false}
1082
+ animate={{
1083
+ height: showCollapsed ? COLLAPSED_HEIGHT : 'auto',
1084
+ }}
1085
+ transition={{
1086
+ duration: 0.3,
1087
+ ease: [0.4, 0, 0.2, 1],
1088
+ }}
1089
+ >
1090
+ <ScrollArea className="w-full whitespace-nowrap">
1091
+ <div
1092
+ className={cn(
1093
+ codeBlockContentClassName,
1094
+ lineHighlightClassNames,
1095
+ lineDiffClassNames,
1096
+ lineFocusedClassNames,
1097
+ wordHighlightClassNames,
1098
+ darkModeClassNames,
1099
+ )}
1100
+ >
1101
+ <SyntaxHighlightedContent
1102
+ code={displayCode}
1103
+ language={normalizeLanguage(isCommand ? 'bash' : language)}
1104
+ />
1105
+ </div>
1106
+ <ScrollBar orientation="horizontal" />
1107
+ </ScrollArea>
1108
+ </motion.div>
1109
+ <FadeOverlay visible={showCollapsed} />
1110
+ </div>
1111
+
1112
+ {isCollapsible && (
1113
+ <ExpandButton
1114
+ isExpanded={isExpanded}
1115
+ hiddenLineCount={hiddenLineCount}
1116
+ onToggle={() => setIsExpanded(!isExpanded)}
1117
+ />
1118
+ )}
1119
+ </div>
1120
+ );
1121
+ }
1122
+
1123
+ export { CodeBlockAction, transformCommand, processCode, matchFileTypeIcon };
1124
+ export type { CodeBlockProps, CodeBlockActionProps, PackageManager };
@@ -0,0 +1,33 @@
1
+ 'use client';
2
+
3
+ import { createContext, type ReactNode, useContext, useMemo } from 'react';
4
+
5
+ // Function props can't cross a server→client boundary, so a copy surface
6
+ // rendered from RSC (e.g. MDX docs) could never receive an `onCopied` callback
7
+ // for toast wiring. This context is the RSC-safe alternative: mount
8
+ // CopyFeedbackProvider once in a client component (wire your toast there — the
9
+ // DS never toasts), and copy surfaces resolve their feedback in a fixed order —
10
+ // explicit prop → this context → nothing beyond the built-in copied icon.
11
+ interface CopyFeedbackContextValue {
12
+ /** Called after a successful copy. Wire your toast here — the DS never toasts. */
13
+ onCopied?: () => void;
14
+ /** Called when the clipboard write fails. */
15
+ onCopyError?: (error: Error) => void;
16
+ }
17
+
18
+ const CopyFeedbackContext = createContext<CopyFeedbackContextValue>({});
19
+
20
+ function CopyFeedbackProvider({
21
+ children,
22
+ onCopied,
23
+ onCopyError,
24
+ }: CopyFeedbackContextValue & { children: ReactNode }) {
25
+ const value = useMemo(() => ({ onCopied, onCopyError }), [onCopied, onCopyError]);
26
+ return <CopyFeedbackContext.Provider value={value}>{children}</CopyFeedbackContext.Provider>;
27
+ }
28
+
29
+ function useCopyFeedback(): CopyFeedbackContextValue {
30
+ return useContext(CopyFeedbackContext);
31
+ }
32
+
33
+ export { CopyFeedbackProvider, useCopyFeedback, type CopyFeedbackContextValue };
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { Button } from '@vendure-io/ui/components/atoms/button';
4
+ import { useCopyFeedback } from '@vendure-io/ui/components/molecules/copy-feedback-provider';
4
5
  import { useCopy } from '@vendure-io/ui/hooks/use-copy';
5
6
  import { cn } from '@vendure-io/ui/lib/utils';
6
7
  import { CheckIcon, CopyIcon } from 'lucide-react';
@@ -11,9 +12,16 @@ interface CopyButtonProps extends Omit<React.ComponentProps<typeof Button>, 'val
11
12
  value: string;
12
13
  /** How long the check-mark feedback stays visible, in ms. @default 2000 */
13
14
  timeout?: number;
14
- /** Called after a successful copy. Wire your toast here — the DS never toasts. */
15
+ /**
16
+ * Called after a successful copy. Wire your toast here — the DS never toasts.
17
+ * Falls back to `CopyFeedbackProvider` when omitted (the RSC-safe path, since
18
+ * function props can't be passed from server components).
19
+ */
15
20
  onCopied?: () => void;
16
- /** Called when the clipboard write fails (e.g. permissions, insecure context). */
21
+ /**
22
+ * Called when the clipboard write fails (e.g. permissions, insecure context).
23
+ * Falls back to `CopyFeedbackProvider` when omitted.
24
+ */
17
25
  onCopyError?: (error: Error) => void;
18
26
  /** Accessible label before copying. @default "Copy" */
19
27
  copyLabel?: string;
@@ -40,6 +48,7 @@ function CopyButton({
40
48
  ...props
41
49
  }: CopyButtonProps) {
42
50
  const { copied, copy } = useCopy({ timeout });
51
+ const copyFeedback = useCopyFeedback();
43
52
 
44
53
  return (
45
54
  <Button
@@ -53,8 +62,9 @@ function CopyButton({
53
62
  onClick?.(event);
54
63
  if (event.defaultPrevented) return;
55
64
  const ok = await copy(value);
56
- if (ok) onCopied?.();
57
- else onCopyError?.(new Error('Failed to copy to the clipboard'));
65
+ if (ok) (onCopied ?? copyFeedback.onCopied)?.();
66
+ else
67
+ (onCopyError ?? copyFeedback.onCopyError)?.(new Error('Failed to copy to the clipboard'));
58
68
  }}
59
69
  {...props}
60
70
  >
@@ -71,9 +81,9 @@ interface CopyableTextProps {
71
81
  className?: string;
72
82
  /** How long the check-mark feedback stays visible, in ms. @default 2000 */
73
83
  timeout?: number;
74
- /** Called after a successful copy. Wire your toast here — the DS never toasts. */
84
+ /** Called after a successful copy. Wire your toast here — the DS never toasts. Falls back to `CopyFeedbackProvider`. */
75
85
  onCopied?: () => void;
76
- /** Called when the clipboard write fails. */
86
+ /** Called when the clipboard write fails. Falls back to `CopyFeedbackProvider`. */
77
87
  onCopyError?: (error: Error) => void;
78
88
  }
79
89
 
@@ -40,7 +40,7 @@ interface IdChipProps {
40
40
  /** Render the copy affordance. @default true */
41
41
  copyable?: boolean;
42
42
  className?: string;
43
- /** Called after a successful copy. Wire your toast here — the DS never toasts. */
43
+ /** Called after a successful copy. Wire your toast here — the DS never toasts. Falls back to `CopyFeedbackProvider`. */
44
44
  onCopied?: () => void;
45
45
  }
46
46