@vendure-io/ui 2.0.0-beta.5 → 2.0.0-beta.7

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.5",
3
+ "version": "2.0.0-beta.7",
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,6 +45,9 @@
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
52
  "@vendure-io/design-tokens": "^2.0.0-beta.5",
50
53
  "class-variance-authority": "^0.7.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: {
@@ -302,22 +302,32 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
302
302
  )
303
303
  }
304
304
 
305
- function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
305
+ function SidebarInset({
306
+ className,
307
+ render,
308
+ ...props
309
+ }: useRender.ComponentProps<"main">) {
306
310
  // The inset variant is our recessed content pane, built on the surface ramp: it
307
311
  // steps up to --surface while the wrapper stays on --background, so the shell
308
312
  // recedes and cards (--surface-raised) lift off the pane. A border carries the
309
313
  // tier in light mode where the ramp collapses to white; flat by design, no drop
310
314
  // shadow (see AGENTS.md "flat look"). The default variant stays flush on canvas.
311
- return (
312
- <main
313
- data-slot="sidebar-inset"
314
- className={cn(
315
- "bg-background relative flex w-full flex-1 flex-col md:peer-data-[variant=inset]:bg-surface md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:border md:peer-data-[variant=inset]:border-border/60 md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
316
- className
317
- )}
318
- {...props}
319
- />
320
- )
315
+ return useRender({
316
+ defaultTagName: "main",
317
+ props: mergeProps<"main">(
318
+ {
319
+ className: cn(
320
+ "bg-background relative flex w-full flex-1 flex-col md:peer-data-[variant=inset]:bg-surface md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:border md:peer-data-[variant=inset]:border-border/60 md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
321
+ className
322
+ ),
323
+ },
324
+ props
325
+ ),
326
+ render,
327
+ state: {
328
+ slot: "sidebar-inset",
329
+ },
330
+ })
321
331
  }
322
332
 
323
333
  function SidebarInput({
@@ -73,16 +73,38 @@ function AppShellHeader({ className, ...props }: React.ComponentProps<'header'>)
73
73
  );
74
74
  }
75
75
 
76
- function AppShellMain({ className, id = 'main-content', ...props }: React.ComponentProps<'main'>) {
76
+ interface AppShellMainProps extends React.ComponentProps<'main'> {
77
+ /** The element responsible for scrolling the main content region. */
78
+ scrollOwner?: 'main' | 'container';
79
+ }
80
+
81
+ function AppShellMain({
82
+ className,
83
+ id = 'main-content',
84
+ scrollOwner = 'main',
85
+ ...props
86
+ }: AppShellMainProps) {
77
87
  return (
78
88
  <main
79
89
  id={id}
80
90
  tabIndex={-1}
81
91
  data-slot="app-shell-main"
82
- className={cn('min-h-0 min-w-0 flex-1 overflow-auto outline-none', className)}
92
+ className={cn(
93
+ scrollOwner === 'main'
94
+ ? 'min-h-0 min-w-0 flex-1 overflow-auto outline-none'
95
+ : 'min-w-0 flex-1 outline-none',
96
+ className,
97
+ )}
83
98
  {...props}
84
99
  />
85
100
  );
86
101
  }
87
102
 
88
- export { AppShell, AppShellSidebar, AppShellContent, AppShellHeader, AppShellMain };
103
+ export {
104
+ AppShell,
105
+ AppShellSidebar,
106
+ AppShellContent,
107
+ AppShellHeader,
108
+ AppShellMain,
109
+ type AppShellMainProps,
110
+ };
@@ -0,0 +1,1115 @@
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 { useCopy } from '@vendure-io/ui/hooks/use-copy';
14
+ import { cn } from '@vendure-io/ui/lib/utils';
15
+ import {
16
+ CheckIcon,
17
+ ChevronDownIcon,
18
+ ChevronUpIcon,
19
+ CodeIcon,
20
+ CopyIcon,
21
+ FileIcon,
22
+ TerminalIcon,
23
+ } from 'lucide-react';
24
+ import { motion } from 'motion/react';
25
+ import {
26
+ type ComponentProps,
27
+ type PropsWithChildren,
28
+ type ReactNode,
29
+ useEffect,
30
+ useId,
31
+ useMemo,
32
+ useRef,
33
+ useState,
34
+ } from 'react';
35
+ import { createHighlighterCore } from 'shiki/core';
36
+ import { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
37
+
38
+ // =============================================================================
39
+ // Types
40
+ // =============================================================================
41
+
42
+ type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun';
43
+
44
+ type CodeBlockProps = PropsWithChildren<ComponentProps<'div'>> & {
45
+ /** Programming language for syntax highlighting */
46
+ language?: string;
47
+ /**
48
+ * Filename shown in the header (overrides a `// filename:` first-line directive).
49
+ * Takes precedence over the package-manager tabs. A block with a filename never
50
+ * shows the switcher, even when `packageManagerSwitcher` is set.
51
+ */
52
+ filename?: string;
53
+ /** Hide the header bar completely */
54
+ hideHeader?: boolean;
55
+ /** Enable npm→pnpm/yarn/bun tab switching for shell blocks containing npm/npx commands. @default false */
56
+ packageManagerSwitcher?: boolean;
57
+ /** Extra toolbar actions rendered before the built-in copy button. Compose with CodeBlockAction. */
58
+ actions?: ReactNode;
59
+ /** Called after a successful copy. Wire your toast here. The DS never toasts. */
60
+ onCopied?: () => void;
61
+ /** Called when the clipboard write fails. */
62
+ onCopyError?: (error: Error) => void;
63
+ };
64
+
65
+ interface CodeBlockActionProps {
66
+ icon: ReactNode;
67
+ label: string;
68
+ onClick: () => void;
69
+ disabled?: boolean;
70
+ }
71
+
72
+ // =============================================================================
73
+ // Constants
74
+ // =============================================================================
75
+
76
+ const PACKAGE_MANAGERS: PackageManager[] = ['npm', 'pnpm', 'yarn', 'bun'];
77
+ const STORAGE_KEY = 'vendure-ui-package-manager';
78
+ const STORAGE_EVENT = 'vendure-ui-package-manager-change';
79
+
80
+ // Expand/collapse constants
81
+ const COLLAPSE_THRESHOLD = 40; // Lines needed to trigger collapse
82
+ const COLLAPSED_HEIGHT = 300; // px height when collapsed (roughly 15-20 lines)
83
+
84
+ const languageLoaders = {
85
+ bash: () => import('@shikijs/langs/bash').then((module) => module.default),
86
+ css: () => import('@shikijs/langs/css').then((module) => module.default),
87
+ dotenv: () => import('@shikijs/langs/dotenv').then((module) => module.default),
88
+ graphql: () => import('@shikijs/langs/graphql').then((module) => module.default),
89
+ html: () => import('@shikijs/langs/html').then((module) => module.default),
90
+ ini: () => import('@shikijs/langs/ini').then((module) => module.default),
91
+ javascript: () => import('@shikijs/langs/javascript').then((module) => module.default),
92
+ json: () => import('@shikijs/langs/json').then((module) => module.default),
93
+ jsonc: () => import('@shikijs/langs/jsonc').then((module) => module.default),
94
+ jsx: () => import('@shikijs/langs/jsx').then((module) => module.default),
95
+ markdown: () => import('@shikijs/langs/markdown').then((module) => module.default),
96
+ mdx: () => import('@shikijs/langs/mdx').then((module) => module.default),
97
+ python: () => import('@shikijs/langs/python').then((module) => module.default),
98
+ shellscript: () => import('@shikijs/langs/shellscript').then((module) => module.default),
99
+ sql: () => import('@shikijs/langs/sql').then((module) => module.default),
100
+ tsx: () => import('@shikijs/langs/tsx').then((module) => module.default),
101
+ typescript: () => import('@shikijs/langs/typescript').then((module) => module.default),
102
+ yaml: () => import('@shikijs/langs/yaml').then((module) => module.default),
103
+ } as const;
104
+
105
+ type SupportedLanguage = keyof typeof languageLoaders;
106
+
107
+ const themeLoaders = [
108
+ () => import('@shikijs/themes/github-light').then((module) => module.default),
109
+ () => import('@shikijs/themes/github-dark-default').then((module) => module.default),
110
+ ] as const;
111
+
112
+ let highlighterPromise: ReturnType<typeof createHighlighterCore> | null = null;
113
+
114
+ function getHighlighter(): ReturnType<typeof createHighlighterCore> {
115
+ highlighterPromise ??= Promise.all([
116
+ Promise.all(themeLoaders.map((loadTheme) => loadTheme())),
117
+ Promise.all(Object.values(languageLoaders).map((loadLanguage) => loadLanguage())),
118
+ ]).then(([themes, languages]) =>
119
+ createHighlighterCore({
120
+ themes,
121
+ langs: languages.flat(),
122
+ engine: createJavaScriptRegexEngine(),
123
+ }),
124
+ );
125
+
126
+ return highlighterPromise;
127
+ }
128
+
129
+ const highlightedCodeCache = new Map<string, Promise<string>>();
130
+
131
+ /**
132
+ * Get the stored package manager preference from localStorage
133
+ */
134
+ function getStoredPackageManager(): PackageManager {
135
+ if (typeof window === 'undefined') return 'npm';
136
+ try {
137
+ const stored = localStorage.getItem(STORAGE_KEY);
138
+ if (stored && PACKAGE_MANAGERS.includes(stored as PackageManager)) {
139
+ return stored as PackageManager;
140
+ }
141
+ } catch {
142
+ // localStorage might not be available
143
+ }
144
+ return 'npm';
145
+ }
146
+
147
+ /**
148
+ * Store the package manager preference in localStorage
149
+ */
150
+ function setStoredPackageManager(pm: PackageManager): void {
151
+ if (typeof window === 'undefined') return;
152
+ try {
153
+ localStorage.setItem(STORAGE_KEY, pm);
154
+ // Dispatch a custom event so other CodeBlock instances can update
155
+ window.dispatchEvent(new CustomEvent(STORAGE_EVENT, { detail: pm }));
156
+ } catch {
157
+ // localStorage might not be available
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Custom hook to manage package manager state with localStorage persistence
163
+ */
164
+ function usePackageManager(): [PackageManager, (pm: PackageManager) => void] {
165
+ const [packageManager, setPackageManager] = useState<PackageManager>('npm');
166
+
167
+ // Initialize from localStorage on mount
168
+ useEffect(() => {
169
+ setPackageManager(getStoredPackageManager());
170
+ }, []);
171
+
172
+ // Listen for changes from other CodeBlock instances
173
+ useEffect(() => {
174
+ const handleChange = (e: CustomEvent<PackageManager>) => {
175
+ setPackageManager(e.detail);
176
+ };
177
+
178
+ window.addEventListener(STORAGE_EVENT, handleChange as EventListener);
179
+ return () => {
180
+ window.removeEventListener(STORAGE_EVENT, handleChange as EventListener);
181
+ };
182
+ }, []);
183
+
184
+ const updatePackageManager = (pm: PackageManager) => {
185
+ setPackageManager(pm);
186
+ setStoredPackageManager(pm);
187
+ };
188
+
189
+ return [packageManager, updatePackageManager];
190
+ }
191
+
192
+ // =============================================================================
193
+ // Utility Functions
194
+ // =============================================================================
195
+
196
+ /**
197
+ * Detects if a single line is an npm/npx command that can be transformed
198
+ */
199
+ function isNpmLine(line: string): boolean {
200
+ const trimmed = line.trim();
201
+ return /^(npm\s+(install|i|add|run|exec|create|init|ci|remove|uninstall|rm)|npx\s+)/.test(
202
+ trimmed,
203
+ );
204
+ }
205
+
206
+ /**
207
+ * Detects if the code contains any package manager commands that can be transformed
208
+ */
209
+ function isPackageManagerCommand(code: string): boolean {
210
+ const lines = code.split('\n');
211
+ return lines.some((line) => isNpmLine(line));
212
+ }
213
+
214
+ /**
215
+ * Transforms a single npm/npx command line to the equivalent for other package managers
216
+ */
217
+ function transformSingleLine(line: string, targetPm: PackageManager): string {
218
+ const trimmed = line.trim();
219
+
220
+ if (targetPm === 'npm') return line;
221
+
222
+ // Handle npx commands
223
+ if (trimmed.startsWith('npx ')) {
224
+ const rest = trimmed.slice(4);
225
+ switch (targetPm) {
226
+ case 'pnpm':
227
+ return `pnpm dlx ${rest}`;
228
+ case 'yarn':
229
+ return `yarn dlx ${rest}`;
230
+ case 'bun':
231
+ return `bunx ${rest}`;
232
+ }
233
+ }
234
+
235
+ // Handle npm create / npm init
236
+ if (/^npm\s+(create|init)\s+/.test(trimmed)) {
237
+ const match = trimmed.match(/^npm\s+(create|init)\s+(.+)$/);
238
+ if (match) {
239
+ const args = match[2];
240
+ switch (targetPm) {
241
+ case 'pnpm':
242
+ return `pnpm create ${args}`;
243
+ case 'yarn':
244
+ return `yarn create ${args}`;
245
+ case 'bun':
246
+ return `bun create ${args}`;
247
+ }
248
+ }
249
+ }
250
+
251
+ // Handle npm install / npm i / npm add (with packages)
252
+ if (/^npm\s+(install|i|add)\s+/.test(trimmed)) {
253
+ const match = trimmed.match(/^npm\s+(install|i|add)\s+(.+)$/);
254
+ if (match) {
255
+ const packages = match[2] ?? '';
256
+ // Handle flags
257
+ const devFlag = packages.includes('-D') || packages.includes('--save-dev');
258
+ const cleanPackages = packages
259
+ .replace(/\s*-D\s*/, ' ')
260
+ .replace(/\s*--save-dev\s*/, ' ')
261
+ .trim();
262
+
263
+ switch (targetPm) {
264
+ case 'pnpm':
265
+ return devFlag ? `pnpm add -D ${cleanPackages}` : `pnpm add ${cleanPackages}`;
266
+ case 'yarn':
267
+ return devFlag ? `yarn add -D ${cleanPackages}` : `yarn add ${cleanPackages}`;
268
+ case 'bun':
269
+ return devFlag ? `bun add -d ${cleanPackages}` : `bun add ${cleanPackages}`;
270
+ }
271
+ }
272
+ }
273
+
274
+ // Handle npm install (without packages - install from package.json)
275
+ if (/^npm\s+(install|i|ci)$/.test(trimmed)) {
276
+ switch (targetPm) {
277
+ case 'pnpm':
278
+ return 'pnpm install';
279
+ case 'yarn':
280
+ return 'yarn';
281
+ case 'bun':
282
+ return 'bun install';
283
+ }
284
+ }
285
+
286
+ // Handle npm run <script>
287
+ if (/^npm\s+run\s+/.test(trimmed)) {
288
+ const match = trimmed.match(/^npm\s+run\s+(.+)$/);
289
+ if (match) {
290
+ const script = match[1];
291
+ switch (targetPm) {
292
+ case 'pnpm':
293
+ return `pnpm run ${script}`;
294
+ case 'yarn':
295
+ return `yarn ${script}`;
296
+ case 'bun':
297
+ return `bun run ${script}`;
298
+ }
299
+ }
300
+ }
301
+
302
+ // Handle npm remove / npm uninstall / npm rm
303
+ if (/^npm\s+(remove|uninstall|rm)\s+/.test(trimmed)) {
304
+ const match = trimmed.match(/^npm\s+(remove|uninstall|rm)\s+(.+)$/);
305
+ if (match) {
306
+ const packages = match[2];
307
+ switch (targetPm) {
308
+ case 'pnpm':
309
+ return `pnpm remove ${packages}`;
310
+ case 'yarn':
311
+ return `yarn remove ${packages}`;
312
+ case 'bun':
313
+ return `bun remove ${packages}`;
314
+ }
315
+ }
316
+ }
317
+
318
+ // Handle npm exec
319
+ if (/^npm\s+exec\s+/.test(trimmed)) {
320
+ const match = trimmed.match(/^npm\s+exec\s+(.+)$/);
321
+ if (match) {
322
+ const rest = match[1];
323
+ switch (targetPm) {
324
+ case 'pnpm':
325
+ return `pnpm exec ${rest}`;
326
+ case 'yarn':
327
+ return `yarn exec ${rest}`;
328
+ case 'bun':
329
+ return `bun x ${rest}`;
330
+ }
331
+ }
332
+ }
333
+
334
+ return line;
335
+ }
336
+
337
+ /**
338
+ * Transforms all npm/npx commands in a code block to the target package manager.
339
+ * Leaves non-npm lines unchanged.
340
+ */
341
+ function transformCommand(code: string, targetPm: PackageManager): string {
342
+ if (targetPm === 'npm') return code;
343
+
344
+ const lines = code.split('\n');
345
+ const transformedLines = lines.map((line) => {
346
+ if (isNpmLine(line)) {
347
+ return transformSingleLine(line, targetPm);
348
+ }
349
+ return line;
350
+ });
351
+
352
+ return transformedLines.join('\n');
353
+ }
354
+
355
+ /**
356
+ * Languages where Shiki transformers work (have // or # comment syntax)
357
+ */
358
+ const SHIKI_TRANSFORMER_LANGUAGES = new Set([
359
+ 'javascript',
360
+ 'js',
361
+ 'typescript',
362
+ 'ts',
363
+ 'tsx',
364
+ 'jsx',
365
+ 'java',
366
+ 'c',
367
+ 'cpp',
368
+ 'csharp',
369
+ 'cs',
370
+ 'go',
371
+ 'rust',
372
+ 'swift',
373
+ 'kotlin',
374
+ 'scala',
375
+ 'php',
376
+ 'ruby',
377
+ 'python',
378
+ 'py',
379
+ 'bash',
380
+ 'shell',
381
+ 'sh',
382
+ 'zsh',
383
+ 'yaml',
384
+ 'yml',
385
+ ]);
386
+
387
+ /**
388
+ * Process code to extract filename directive and strip Shiki notations for unsupported languages.
389
+ * Supports: // filename: path/to/file.ts (must be first line)
390
+ *
391
+ * Note: Line highlighting uses Shiki's native notation:
392
+ * - // [!code highlight] for JS/TS (at end of line)
393
+ * - # [!code highlight] for bash/shell (at end of line)
394
+ * - // [!code ++] and // [!code --] for diff
395
+ * - // [!code focus] for focus mode
396
+ *
397
+ * For languages like 'text' that don't support comments, these notations are stripped.
398
+ */
399
+ function processCode(
400
+ code: string,
401
+ language?: string,
402
+ ): {
403
+ cleanCode: string;
404
+ extractedFilename?: string;
405
+ } {
406
+ const lines = code.split('\n');
407
+ const cleanLines: string[] = [];
408
+ let extractedFilename: string | undefined;
409
+
410
+ // Check if we need to strip Shiki notations (for languages without comment support)
411
+ const shouldStripNotations = !SHIKI_TRANSFORMER_LANGUAGES.has(language?.toLowerCase() || '');
412
+
413
+ for (let i = 0; i < lines.length; i++) {
414
+ let line = lines[i] ?? '';
415
+ const trimmedLine = line.trim();
416
+
417
+ // Check for filename directive (must be first non-empty line)
418
+ if (i === 0 || (cleanLines.length === 0 && !extractedFilename)) {
419
+ const filenameMatch = trimmedLine.match(/^\/\/\s*filename:\s*(.+)$/);
420
+ if (filenameMatch) {
421
+ extractedFilename = filenameMatch[1]?.trim();
422
+ continue; // Don't include this directive line
423
+ }
424
+ }
425
+
426
+ // Strip Shiki notations for unsupported languages
427
+ if (shouldStripNotations) {
428
+ // Remove // [!code ...] notations
429
+ line = line.replace(/\s*\/\/\s*\[!code\s+[^\]]+\]\s*$/, '');
430
+ // Remove # [!code ...] notations
431
+ line = line.replace(/\s*#\s*\[!code\s+[^\]]+\]\s*$/, '');
432
+ // Skip lines that are only the notation (e.g., standalone "// [!code highlight]")
433
+ if (trimmedLine.match(/^(\/\/|#)\s*\[!code\s+[^\]]+\]\s*$/)) {
434
+ continue;
435
+ }
436
+ }
437
+
438
+ // Add line to clean output
439
+ cleanLines.push(line);
440
+ }
441
+
442
+ return {
443
+ cleanCode: cleanLines.join('\n'),
444
+ extractedFilename,
445
+ };
446
+ }
447
+
448
+ // =============================================================================
449
+ // Shiki Highlighting
450
+ // =============================================================================
451
+
452
+ /**
453
+ * Highlight code using Shiki with all transformers.
454
+ * Uses Shiki's native notation for highlighting:
455
+ * - // [!code highlight] - highlight a line (use language-appropriate comment)
456
+ * - // [!code ++] / // [!code --] - diff highlighting
457
+ * - // [!code focus] - focus mode (blur other lines)
458
+ * - // [!code word:myVar] - highlight specific word
459
+ * - // [!code error] / // [!code warning] - error levels
460
+ */
461
+ async function highlightCode(code: string, language: SupportedLanguage): Promise<string> {
462
+ const cacheKey = `${language}:${code}`;
463
+ const cached = highlightedCodeCache.get(cacheKey);
464
+ if (cached) return cached;
465
+
466
+ const highlighted = getHighlighter().then((highlighter) =>
467
+ highlighter.codeToHtml(code, {
468
+ lang: language,
469
+ themes: {
470
+ light: 'github-light',
471
+ dark: 'github-dark-default',
472
+ },
473
+ transformers: [
474
+ transformerNotationDiff({ matchAlgorithm: 'v3' }),
475
+ transformerNotationHighlight({ matchAlgorithm: 'v3' }),
476
+ transformerNotationWordHighlight({ matchAlgorithm: 'v3' }),
477
+ transformerNotationFocus({ matchAlgorithm: 'v3' }),
478
+ transformerNotationErrorLevel({ matchAlgorithm: 'v3' }),
479
+ ],
480
+ }),
481
+ );
482
+ highlightedCodeCache.set(cacheKey, highlighted);
483
+ return highlighted;
484
+ }
485
+
486
+ /**
487
+ * Language aliases that map to Shiki's bundled language names
488
+ */
489
+ const LANGUAGE_ALIASES: Record<string, SupportedLanguage> = {
490
+ env: 'dotenv',
491
+ js: 'javascript',
492
+ ts: 'typescript',
493
+ sh: 'bash',
494
+ shell: 'bash',
495
+ yml: 'yaml',
496
+ py: 'python',
497
+ md: 'markdown',
498
+ plaintext: 'ini',
499
+ text: 'ini',
500
+ chroma: 'ini',
501
+ };
502
+
503
+ /**
504
+ * Normalize language identifier for Shiki/BundledLanguage.
505
+ * Falls back to 'ini' for unsupported languages (minimal highlighting).
506
+ */
507
+ function normalizeLanguage(lang?: string): SupportedLanguage {
508
+ const normalized = lang?.toLowerCase() || 'ini';
509
+
510
+ // Check for aliases first
511
+ const alias = LANGUAGE_ALIASES[normalized];
512
+ if (alias) {
513
+ return alias;
514
+ }
515
+
516
+ // Check if the language is one of the explicitly bundled grammars.
517
+ if (normalized in languageLoaders) {
518
+ return normalized as SupportedLanguage;
519
+ }
520
+
521
+ // Fallback to 'ini' for unsupported languages (has minimal highlighting)
522
+ return 'ini';
523
+ }
524
+
525
+ /**
526
+ * Escape HTML special characters for safe display (fallback)
527
+ */
528
+ function escapeHtml(text: string): string {
529
+ return text
530
+ .replace(/&/g, '&amp;')
531
+ .replace(/</g, '&lt;')
532
+ .replace(/>/g, '&gt;')
533
+ .replace(/"/g, '&quot;')
534
+ .replace(/'/g, '&#039;');
535
+ }
536
+
537
+ // =============================================================================
538
+ // File Type Icons
539
+ // =============================================================================
540
+
541
+ /**
542
+ * Brand glyphs for common file extensions, shown next to the filename in the
543
+ * header. Path data is inlined from Simple Icons (CC0) so the package takes no
544
+ * icon dependency; glyphs render monochrome via currentColor and inherit the
545
+ * muted header foreground. Unknown extensions fall back to the generic
546
+ * lucide FileIcon.
547
+ */
548
+ interface FileTypeIconDef {
549
+ /** Brand name of the glyph */
550
+ title: string;
551
+ /** SVG path data on a 24x24 viewBox */
552
+ path: string;
553
+ }
554
+
555
+ const typescriptIcon: FileTypeIconDef = {
556
+ title: 'TypeScript',
557
+ 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',
558
+ };
559
+
560
+ const reactIcon: FileTypeIconDef = {
561
+ title: 'React',
562
+ 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',
563
+ };
564
+
565
+ const javascriptIcon: FileTypeIconDef = {
566
+ title: 'JavaScript',
567
+ 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',
568
+ };
569
+
570
+ const jsonIcon: FileTypeIconDef = {
571
+ title: 'JSON',
572
+ 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',
573
+ };
574
+
575
+ const htmlIcon: FileTypeIconDef = {
576
+ title: 'HTML5',
577
+ 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',
578
+ };
579
+
580
+ const cssIcon: FileTypeIconDef = {
581
+ title: 'CSS',
582
+ 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',
583
+ };
584
+
585
+ const markdownIcon: FileTypeIconDef = {
586
+ title: 'Markdown',
587
+ 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',
588
+ };
589
+
590
+ const mdxIcon: FileTypeIconDef = {
591
+ title: 'MDX',
592
+ 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',
593
+ };
594
+
595
+ const yamlIcon: FileTypeIconDef = {
596
+ title: 'YAML',
597
+ 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',
598
+ };
599
+
600
+ const graphqlIcon: FileTypeIconDef = {
601
+ title: 'GraphQL',
602
+ 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',
603
+ };
604
+
605
+ const bashIcon: FileTypeIconDef = {
606
+ title: 'Bash',
607
+ 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',
608
+ };
609
+
610
+ const dockerIcon: FileTypeIconDef = {
611
+ title: 'Docker',
612
+ 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',
613
+ };
614
+
615
+ const FILE_TYPE_ICONS: Record<string, FileTypeIconDef> = {
616
+ ts: typescriptIcon,
617
+ mts: typescriptIcon,
618
+ cts: typescriptIcon,
619
+ tsx: reactIcon,
620
+ jsx: reactIcon,
621
+ js: javascriptIcon,
622
+ mjs: javascriptIcon,
623
+ cjs: javascriptIcon,
624
+ json: jsonIcon,
625
+ jsonc: jsonIcon,
626
+ html: htmlIcon,
627
+ css: cssIcon,
628
+ md: markdownIcon,
629
+ mdx: mdxIcon,
630
+ yml: yamlIcon,
631
+ yaml: yamlIcon,
632
+ graphql: graphqlIcon,
633
+ gql: graphqlIcon,
634
+ sh: bashIcon,
635
+ bash: bashIcon,
636
+ zsh: bashIcon,
637
+ dockerfile: dockerIcon,
638
+ };
639
+
640
+ /**
641
+ * Resolve the brand icon for a filename by its extension. Matching uses the
642
+ * basename, so directory names never interfere. `Dockerfile` matches by full
643
+ * basename since it has no extension. Returns undefined for unknown extensions
644
+ * and dotfiles; the header then falls back to the generic file icon.
645
+ */
646
+ function matchFileTypeIcon(filename: string): FileTypeIconDef | undefined {
647
+ const basename = filename.split('/').pop()?.toLowerCase() ?? '';
648
+ if (basename === 'dockerfile') return FILE_TYPE_ICONS.dockerfile;
649
+ const dotIndex = basename.lastIndexOf('.');
650
+ if (dotIndex <= 0) return undefined;
651
+ return FILE_TYPE_ICONS[basename.slice(dotIndex + 1)];
652
+ }
653
+
654
+ function FileTypeIcon({ icon }: { icon: FileTypeIconDef }) {
655
+ return (
656
+ <svg viewBox="0 0 24 24" className="size-3.5 shrink-0 fill-current" aria-hidden="true">
657
+ <path d={icon.path} />
658
+ </svg>
659
+ );
660
+ }
661
+
662
+ // =============================================================================
663
+ // Style Constants
664
+ // =============================================================================
665
+
666
+ const darkModeClassNames = cn(
667
+ 'dark:[&_.shiki]:!text-[var(--shiki-dark)]',
668
+ 'dark:[&_.shiki]:![font-style:var(--shiki-dark-font-style)]',
669
+ 'dark:[&_.shiki]:![font-weight:var(--shiki-dark-font-weight)]',
670
+ 'dark:[&_.shiki]:![text-decoration:var(--shiki-dark-text-decoration)]',
671
+ 'dark:[&_.shiki_span]:!text-[var(--shiki-dark)]',
672
+ 'dark:[&_.shiki_span]:![font-style:var(--shiki-dark-font-style)]',
673
+ 'dark:[&_.shiki_span]:![font-weight:var(--shiki-dark-font-weight)]',
674
+ 'dark:[&_.shiki_span]:![text-decoration:var(--shiki-dark-text-decoration)]',
675
+ );
676
+
677
+ const lineHighlightClassNames = cn(
678
+ '[&_.line.highlighted]:bg-code-highlight',
679
+ '[&_.line.highlighted]:after:bg-code-highlight-accent',
680
+ '[&_.line.highlighted]:after:absolute',
681
+ '[&_.line.highlighted]:after:left-0',
682
+ '[&_.line.highlighted]:after:top-0',
683
+ '[&_.line.highlighted]:after:bottom-0',
684
+ '[&_.line.highlighted]:after:w-0.5',
685
+ );
686
+
687
+ const lineDiffClassNames = cn(
688
+ '[&_.line.diff]:after:absolute',
689
+ '[&_.line.diff]:after:left-0',
690
+ '[&_.line.diff]:after:top-0',
691
+ '[&_.line.diff]:after:bottom-0',
692
+ '[&_.line.diff]:after:w-0.5',
693
+ '[&_.line.diff.add]:bg-code-diff-add',
694
+ '[&_.line.diff.add]:after:bg-code-diff-add-accent',
695
+ '[&_.line.diff.remove]:bg-code-diff-remove',
696
+ '[&_.line.diff.remove]:after:bg-code-diff-remove-accent',
697
+ );
698
+
699
+ const lineFocusedClassNames = cn(
700
+ '[&_code:has(.focused)_.line]:blur-[2px]',
701
+ '[&_code:has(.focused)_.line.focused]:blur-none',
702
+ );
703
+
704
+ const wordHighlightClassNames = cn('[&_.highlighted-word]:bg-code-highlight');
705
+
706
+ // =============================================================================
707
+ // Sub-Components
708
+ // =============================================================================
709
+
710
+ /**
711
+ * Tooltip icon-button used for code-block toolbar actions (and the built-in copy
712
+ * button). Compose these into the `actions` slot of {@link CodeBlock}.
713
+ */
714
+ function CodeBlockAction({ icon, label, onClick, disabled }: CodeBlockActionProps) {
715
+ return (
716
+ <Tooltip>
717
+ <TooltipTrigger
718
+ render={
719
+ <Button
720
+ variant="ghost"
721
+ size="icon"
722
+ onClick={onClick}
723
+ aria-label={label}
724
+ disabled={disabled}
725
+ className={cn('h-7 w-7', disabled && 'cursor-not-allowed opacity-50')}
726
+ />
727
+ }
728
+ >
729
+ {icon}
730
+ </TooltipTrigger>
731
+ <TooltipContent side="top">{label}</TooltipContent>
732
+ </Tooltip>
733
+ );
734
+ }
735
+
736
+ interface CodeBlockToolbarProps {
737
+ onCopy: () => void;
738
+ isCopied: boolean;
739
+ actions?: ReactNode;
740
+ }
741
+
742
+ function CodeBlockToolbar({ onCopy, isCopied, actions }: CodeBlockToolbarProps) {
743
+ return (
744
+ <div className="flex items-center gap-0.5">
745
+ {actions}
746
+ <CodeBlockAction
747
+ icon={
748
+ isCopied ? <CheckIcon className="size-4 text-success" /> : <CopyIcon className="size-4" />
749
+ }
750
+ label={isCopied ? 'Copied!' : 'Copy code'}
751
+ onClick={onCopy}
752
+ />
753
+ </div>
754
+ );
755
+ }
756
+
757
+ interface PackageManagerTabsProps {
758
+ activeManager: PackageManager;
759
+ onSelect: (pm: PackageManager) => void;
760
+ id: string;
761
+ }
762
+
763
+ function PackageManagerTabs({ activeManager, onSelect, id }: PackageManagerTabsProps) {
764
+ return (
765
+ // clip-path instead of overflow-hidden: overflow clips children at the
766
+ // padding box, so the active-tab underline could never cover the bottom
767
+ // border. Clipping at the border box lets it overlay the border row while
768
+ // the corners stay rounded.
769
+ <div className="bg-background border-border inline-flex rounded-md border text-xs [clip-path:inset(0_round_var(--radius-md))]">
770
+ {PACKAGE_MANAGERS.map((pm, index) => (
771
+ <div key={pm} className="flex items-center">
772
+ {index > 0 && <div className="bg-border h-4 w-px" aria-hidden="true" />}
773
+ <Button
774
+ variant="ghost"
775
+ size="sm"
776
+ className={cn(
777
+ 'relative h-auto rounded-none px-3 py-1.5 text-xs font-medium transition-colors',
778
+ activeManager === pm
779
+ ? 'bg-muted text-foreground'
780
+ : 'text-muted-foreground hover:text-foreground hover:bg-muted/50',
781
+ )}
782
+ onClick={() => onSelect(pm)}
783
+ >
784
+ {pm}
785
+ {activeManager === pm && (
786
+ <motion.div
787
+ className="bg-brand absolute inset-x-0 -bottom-px mx-auto h-0.5 w-full"
788
+ layoutId={`codeblock-pm-tab-${id}`}
789
+ initial={false}
790
+ transition={{
791
+ type: 'spring',
792
+ stiffness: 500,
793
+ damping: 30,
794
+ }}
795
+ />
796
+ )}
797
+ </Button>
798
+ </div>
799
+ ))}
800
+ </div>
801
+ );
802
+ }
803
+
804
+ interface CodeBlockHeaderProps {
805
+ filename?: string;
806
+ language?: string;
807
+ isCommand?: boolean;
808
+ showPackageManagerTabs?: boolean;
809
+ activeManager?: PackageManager;
810
+ onSelectManager?: (pm: PackageManager) => void;
811
+ toolbar: ReactNode;
812
+ id: string;
813
+ }
814
+
815
+ function CodeBlockHeader({
816
+ filename,
817
+ language,
818
+ isCommand,
819
+ showPackageManagerTabs,
820
+ activeManager,
821
+ onSelectManager,
822
+ toolbar,
823
+ id,
824
+ }: CodeBlockHeaderProps) {
825
+ // Determine what label to show
826
+ const getLabel = () => {
827
+ if (filename) {
828
+ const fileTypeIcon = matchFileTypeIcon(filename);
829
+ return (
830
+ <div className="text-muted-foreground flex items-center gap-1.5 text-sm">
831
+ {fileTypeIcon ? <FileTypeIcon icon={fileTypeIcon} /> : <FileIcon className="size-3.5" />}
832
+ <span>{filename}</span>
833
+ </div>
834
+ );
835
+ }
836
+
837
+ if (showPackageManagerTabs && activeManager && onSelectManager) {
838
+ return (
839
+ <PackageManagerTabs activeManager={activeManager} onSelect={onSelectManager} id={id} />
840
+ );
841
+ }
842
+
843
+ if (isCommand) {
844
+ return (
845
+ <div className="text-muted-foreground flex items-center gap-1.5 text-sm">
846
+ <TerminalIcon className="size-3.5" />
847
+ <span>Terminal</span>
848
+ </div>
849
+ );
850
+ }
851
+
852
+ // Default: show language or "Code"
853
+ return (
854
+ <div className="text-muted-foreground flex items-center gap-1.5 text-sm">
855
+ <CodeIcon className="size-3.5" />
856
+ <span>{language ? language.charAt(0).toUpperCase() + language.slice(1) : 'Code'}</span>
857
+ </div>
858
+ );
859
+ };
860
+
861
+ return (
862
+ <div className="bg-muted/50 border-border flex items-center justify-between border-b px-3 py-2">
863
+ <div className="flex items-center gap-2">{getLabel()}</div>
864
+ {toolbar}
865
+ </div>
866
+ );
867
+ }
868
+
869
+ interface FadeOverlayProps {
870
+ visible: boolean;
871
+ }
872
+
873
+ function FadeOverlay({ visible }: FadeOverlayProps) {
874
+ return (
875
+ <div
876
+ className={cn(
877
+ 'from-card pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t to-transparent transition-opacity duration-300',
878
+ visible ? 'opacity-100' : 'opacity-0',
879
+ )}
880
+ aria-hidden="true"
881
+ />
882
+ );
883
+ }
884
+
885
+ interface ExpandButtonProps {
886
+ isExpanded: boolean;
887
+ hiddenLineCount: number;
888
+ onToggle: () => void;
889
+ }
890
+
891
+ function ExpandButton({ isExpanded, hiddenLineCount, onToggle }: ExpandButtonProps) {
892
+ return (
893
+ <div className="border-border bg-muted/30 flex items-center justify-center border-t py-2">
894
+ <Button
895
+ variant="ghost"
896
+ size="sm"
897
+ onClick={onToggle}
898
+ className="text-muted-foreground hover:text-foreground gap-1.5 text-xs"
899
+ >
900
+ {isExpanded ? (
901
+ <>
902
+ <ChevronUpIcon className="size-3.5" />
903
+ Show less
904
+ </>
905
+ ) : (
906
+ <>
907
+ <ChevronDownIcon className="size-3.5" />
908
+ Show {hiddenLineCount} more lines
909
+ </>
910
+ )}
911
+ </Button>
912
+ </div>
913
+ );
914
+ }
915
+
916
+ const codeBlockContentClassName = cn(
917
+ 'bg-transparent text-sm',
918
+ '[&_pre]:py-4',
919
+ '[&_.shiki]:!bg-transparent',
920
+ '[&_code]:w-full',
921
+ '[&_code]:grid',
922
+ '[&_code]:overflow-x-auto',
923
+ '[&_code]:bg-transparent',
924
+ '[&_.line]:px-4',
925
+ '[&_.line]:w-full',
926
+ '[&_.line]:relative',
927
+ );
928
+
929
+ // =============================================================================
930
+ // Syntax Highlighted Content Component
931
+ // =============================================================================
932
+
933
+ interface SyntaxHighlightedContentProps {
934
+ code: string;
935
+ language: SupportedLanguage;
936
+ }
937
+
938
+ function SyntaxHighlightedContent({ code, language }: SyntaxHighlightedContentProps) {
939
+ const [html, setHtml] = useState<string | null>(null);
940
+
941
+ useEffect(() => {
942
+ highlightCode(code, language)
943
+ .then(setHtml)
944
+ .catch((error) => {
945
+ console.error('Shiki highlighting failed:', error);
946
+ // Fallback to escaped plain text
947
+ setHtml(`<pre class="shiki"><code>${escapeHtml(code)}</code></pre>`);
948
+ });
949
+ }, [code, language]);
950
+
951
+ if (!html) {
952
+ // Show fallback while loading
953
+ return (
954
+ <pre className="shiki py-4">
955
+ <code className="grid w-full overflow-x-auto bg-transparent">
956
+ {code.split('\n').map((line, i) => (
957
+ <span key={i} className="line relative w-full px-4">
958
+ {line}
959
+ </span>
960
+ ))}
961
+ </code>
962
+ </pre>
963
+ );
964
+ }
965
+
966
+ return <div dangerouslySetInnerHTML={{ __html: html }} />;
967
+ }
968
+
969
+ // =============================================================================
970
+ // Main Component
971
+ // =============================================================================
972
+
973
+ /**
974
+ * Shiki-highlighted code block with a header, copy button, collapse/expand for
975
+ * long snippets, and an optional npm→pnpm/yarn/bun package-manager switcher.
976
+ * Highlighting is lazy (a shared highlighter singleton + per-snippet cache), and
977
+ * line/diff/focus/word decorations use Shiki's `[!code ...]` notations. The DS
978
+ * never toasts. Wire `onCopied`/`onCopyError` to your own feedback.
979
+ */
980
+ export function CodeBlock({
981
+ className,
982
+ language,
983
+ filename,
984
+ children,
985
+ hideHeader = false,
986
+ packageManagerSwitcher = false,
987
+ actions,
988
+ onCopied,
989
+ onCopyError,
990
+ ...props
991
+ }: CodeBlockProps) {
992
+ const { copied, copy } = useCopy();
993
+ const [activePackageManager, setActivePackageManager] = usePackageManager();
994
+ const [isExpanded, setIsExpanded] = useState(false);
995
+ const containerRef = useRef<HTMLDivElement>(null);
996
+ const instanceId = useId();
997
+
998
+ // Process the raw code
999
+ const rawCode = useMemo(() => {
1000
+ return typeof children === 'string'
1001
+ ? children
1002
+ : Array.isArray(children)
1003
+ ? children.join('')
1004
+ : String(children || '');
1005
+ }, [children]);
1006
+
1007
+ // Process code directives (filename extraction, strip notations for unsupported languages)
1008
+ const { cleanCode, extractedFilename } = useMemo(
1009
+ () => processCode(rawCode, language),
1010
+ [rawCode, language],
1011
+ );
1012
+
1013
+ // Use provided filename or extracted one
1014
+ const displayFilename = filename || extractedFilename;
1015
+
1016
+ // Detect if this is a package manager command (only when the switcher is enabled)
1017
+ const isShellLanguage =
1018
+ language?.toLowerCase() === 'bash' ||
1019
+ language?.toLowerCase() === 'shell' ||
1020
+ language?.toLowerCase() === 'sh';
1021
+ const isCommand = packageManagerSwitcher && isShellLanguage && isPackageManagerCommand(cleanCode);
1022
+
1023
+ // Get the display code (transformed for selected package manager if applicable)
1024
+ const displayCode = useMemo(() => {
1025
+ if (isCommand) {
1026
+ return transformCommand(cleanCode, activePackageManager);
1027
+ }
1028
+ return cleanCode;
1029
+ }, [cleanCode, isCommand, activePackageManager]);
1030
+
1031
+ // Calculate if the code block is collapsible
1032
+ const lineCount = useMemo(() => displayCode.split('\n').length, [displayCode]);
1033
+ const isCollapsible = lineCount > COLLAPSE_THRESHOLD;
1034
+ const hiddenLineCount = lineCount - Math.floor(COLLAPSED_HEIGHT / 24); // Approximate visible lines based on line height
1035
+
1036
+ const handleCopy = async () => {
1037
+ const ok = await copy(displayCode);
1038
+ if (ok) onCopied?.();
1039
+ else onCopyError?.(new Error('Failed to copy to the clipboard'));
1040
+ };
1041
+
1042
+ const toolbar = <CodeBlockToolbar onCopy={handleCopy} isCopied={copied} actions={actions} />;
1043
+
1044
+ const showCollapsed = isCollapsible && !isExpanded;
1045
+
1046
+ return (
1047
+ <div
1048
+ data-slot="code-block"
1049
+ {...props}
1050
+ className={cn(
1051
+ 'bg-card not-prose border-border relative mb-4 w-full contain-inline-size overflow-hidden rounded-md border text-sm lg:text-base',
1052
+ className,
1053
+ )}
1054
+ ref={containerRef}
1055
+ >
1056
+ {!hideHeader && (
1057
+ <CodeBlockHeader
1058
+ filename={displayFilename}
1059
+ language={language}
1060
+ isCommand={isCommand}
1061
+ showPackageManagerTabs={isCommand}
1062
+ activeManager={activePackageManager}
1063
+ onSelectManager={setActivePackageManager}
1064
+ toolbar={toolbar}
1065
+ id={instanceId}
1066
+ />
1067
+ )}
1068
+
1069
+ <div className="relative">
1070
+ <motion.div
1071
+ className="overflow-hidden"
1072
+ initial={false}
1073
+ animate={{
1074
+ height: showCollapsed ? COLLAPSED_HEIGHT : 'auto',
1075
+ }}
1076
+ transition={{
1077
+ duration: 0.3,
1078
+ ease: [0.4, 0, 0.2, 1],
1079
+ }}
1080
+ >
1081
+ <ScrollArea className="w-full whitespace-nowrap">
1082
+ <div
1083
+ className={cn(
1084
+ codeBlockContentClassName,
1085
+ lineHighlightClassNames,
1086
+ lineDiffClassNames,
1087
+ lineFocusedClassNames,
1088
+ wordHighlightClassNames,
1089
+ darkModeClassNames,
1090
+ )}
1091
+ >
1092
+ <SyntaxHighlightedContent
1093
+ code={displayCode}
1094
+ language={normalizeLanguage(isCommand ? 'bash' : language)}
1095
+ />
1096
+ </div>
1097
+ <ScrollBar orientation="horizontal" />
1098
+ </ScrollArea>
1099
+ </motion.div>
1100
+ <FadeOverlay visible={showCollapsed} />
1101
+ </div>
1102
+
1103
+ {isCollapsible && (
1104
+ <ExpandButton
1105
+ isExpanded={isExpanded}
1106
+ hiddenLineCount={hiddenLineCount}
1107
+ onToggle={() => setIsExpanded(!isExpanded)}
1108
+ />
1109
+ )}
1110
+ </div>
1111
+ );
1112
+ }
1113
+
1114
+ export { CodeBlockAction, transformCommand, processCode, matchFileTypeIcon };
1115
+ export type { CodeBlockProps, CodeBlockActionProps, PackageManager };