@vendure-io/ui 2.0.0-beta.6 → 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.
|
|
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: {
|
|
@@ -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, '&')
|
|
531
|
+
.replace(/</g, '<')
|
|
532
|
+
.replace(/>/g, '>')
|
|
533
|
+
.replace(/"/g, '"')
|
|
534
|
+
.replace(/'/g, ''');
|
|
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 };
|