diff --git a/apps/web/package.json b/apps/web/package.json index c97346df5c..6c16e541df 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -64,6 +64,7 @@ "react-use": "^17.6.0", "recharts": "^2.15.3", "require-in-the-middle": "^7.5.2", + "shiki": "^3.7.0", "sonner": "^2.0.3", "stripe": "^18.2.0", "tailwind-merge": "^3.3.0", diff --git a/apps/web/src/components/ui/CodeBlock.tsx b/apps/web/src/components/ui/CodeBlock.tsx index b5c6862c10..fa7808d7be 100644 --- a/apps/web/src/components/ui/CodeBlock.tsx +++ b/apps/web/src/components/ui/CodeBlock.tsx @@ -1,30 +1,24 @@ 'use client'; import { MermaidDiagram } from './MermaidDiagram'; -import type { Element } from 'hast'; +import { SyntaxHighlighter } from './SyntaxHighlighter'; interface CodeBlockProps extends React.HTMLAttributes { children?: React.ReactNode; className?: string; - inline?: boolean; - node?: Element; } export const CodeBlock = ({ children, className, - inline, ...props }: CodeBlockProps) => { - // Extract language from className (format: "language-xxx") const match = /language-(\w+)/.exec(className || ''); const language = match ? match[1] : ''; - - // Convert children to string const code = String(children).replace(/\n$/, ''); - // If it's inline code or not mermaid, render as regular code - if (inline || language !== 'mermaid') { + // No language = inline code (from `backticks`) + if (!match) { return ( {children} @@ -32,15 +26,13 @@ export const CodeBlock = ({ ); } - // If it's a mermaid code block, render with MermaidDiagram + // Mermaid diagrams if (language === 'mermaid') { - return ; + return ; } - // Fallback to regular code block + // Code blocks with syntax highlighting return ( - - {children} - + ); }; diff --git a/apps/web/src/components/ui/SyntaxHighlighter.tsx b/apps/web/src/components/ui/SyntaxHighlighter.tsx new file mode 100644 index 0000000000..1a27921733 --- /dev/null +++ b/apps/web/src/components/ui/SyntaxHighlighter.tsx @@ -0,0 +1,170 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { useTheme } from 'next-themes'; +import type { BundledLanguage, BundledTheme, HighlighterGeneric } from 'shiki'; + +interface SyntaxHighlighterProps { + code: string; + language: string; + className?: string; +} + +// Cache for the highlighter instance +let highlighterCache: HighlighterGeneric | null = + null; +let highlighterPromise: Promise< + HighlighterGeneric +> | null = null; + +// Common languages to preload for better performance +const COMMON_LANGUAGES: BundledLanguage[] = [ + 'javascript', + 'typescript', + 'jsx', + 'tsx', + 'python', + 'java', + 'go', + 'rust', + 'cpp', + 'c', + 'csharp', + 'php', + 'ruby', + 'swift', + 'kotlin', + 'scala', + 'html', + 'css', + 'scss', + 'json', + 'yaml', + 'xml', + 'markdown', + 'bash', + 'shell', + 'sql', + 'dockerfile', +]; + +const getHighlighter = async () => { + if (highlighterCache) { + return highlighterCache; + } + + if (highlighterPromise) { + return highlighterPromise; + } + + highlighterPromise = (async () => { + const { createHighlighter } = await import('shiki'); + + const highlighter = await createHighlighter({ + themes: ['github-light', 'github-dark'], + langs: COMMON_LANGUAGES, + }); + + highlighterCache = highlighter; + return highlighter; + })(); + + return highlighterPromise; +}; + +export const SyntaxHighlighter = ({ + code, + language, + className, +}: SyntaxHighlighterProps) => { + const [highlightedCode, setHighlightedCode] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const { theme, systemTheme } = useTheme(); + + const currentTheme = theme === 'system' ? systemTheme : theme; + const shikiTheme = currentTheme === 'dark' ? 'github-dark' : 'github-light'; + + const highlightCode = useCallback(async () => { + try { + setIsLoading(true); + setError(null); + + const highlighter = await getHighlighter(); + + // Check if the language is supported, fallback to 'text' if not + const supportedLanguages = highlighter.getLoadedLanguages(); + const langToUse = supportedLanguages.includes(language as BundledLanguage) + ? (language as BundledLanguage) + : 'text'; + + // If the language isn't loaded yet, try to load it + if (!supportedLanguages.includes(langToUse) && langToUse !== 'text') { + try { + await highlighter.loadLanguage(langToUse); + } catch { + // If loading fails, fall back to text + } + } + + const html = highlighter.codeToHtml(code, { + lang: langToUse, + theme: shikiTheme, + transformers: [ + { + pre(node) { + // Remove default background and padding since we'll handle it with CSS + if (node.properties.style) { + node.properties.style = (node.properties.style as string) + .replace(/background-color:[^;]+;?/g, '') + .replace(/padding:[^;]+;?/g, ''); + } + }, + }, + ], + }); + + setHighlightedCode(html); + } catch (err) { + console.error('Failed to highlight code:', err); + setError('Failed to highlight code'); + } finally { + setIsLoading(false); + } + }, [code, language, shikiTheme]); + + useEffect(() => { + highlightCode(); + }, [highlightCode]); + + if (error) { + // Fallback to plain code if highlighting fails + return ( +
+        {code}
+      
+ ); + } + + if (isLoading) { + // Show loading state with plain code + return ( +
+        
+          {code}
+        
+      
+ ); + } + + return ( +
pre]:!bg-transparent [&>pre]:!p-0 [&>pre]:!m-0 ${className || ''}`} + dangerouslySetInnerHTML={{ __html: highlightedCode }} + /> + ); +}; diff --git a/apps/web/src/components/ui/__tests__/CodeBlock.test.tsx b/apps/web/src/components/ui/__tests__/CodeBlock.test.tsx new file mode 100644 index 0000000000..41a7ac02ad --- /dev/null +++ b/apps/web/src/components/ui/__tests__/CodeBlock.test.tsx @@ -0,0 +1,99 @@ +import { render, screen } from '@testing-library/react'; +import { ThemeProvider } from 'next-themes'; +import { CodeBlock } from '../CodeBlock'; + +// Mock the SyntaxHighlighter component +vi.mock('../SyntaxHighlighter', () => ({ + SyntaxHighlighter: ({ + code, + language, + className, + }: { + code: string; + language: string; + className?: string; + }) => ( +
+ {code || ''} +
+ ), +})); + +// Mock the MermaidDiagram component +vi.mock('../MermaidDiagram', () => ({ + MermaidDiagram: ({ + chart, + className, + }: { + chart: string; + className?: string; + }) => ( +
+ {chart} +
+ ), +})); + +const renderWithTheme = (component: React.ReactElement) => { + return render( + + {component} + , + ); +}; + +describe('CodeBlock', () => { + it('renders inline code as regular code element', () => { + renderWithTheme(const x = 1;); + + const codeElement = screen.getByText(/const/).closest('code'); + expect(codeElement?.tagName).toBe('CODE'); + }); + + it('renders mermaid code blocks using MermaidDiagram component', () => { + const mermaidCode = `graph TD + A[Start] --> B{Is it working?}`; + + renderWithTheme( + {mermaidCode}, + ); + + expect(screen.getByTestId('mermaid-diagram')).toBeInTheDocument(); + }); + + it('renders code blocks with language using SyntaxHighlighter', () => { + renderWithTheme( + + console.log('test'); + , + ); + + const syntaxHighlighter = screen.getByTestId('syntax-highlighter'); + expect(syntaxHighlighter).toBeInTheDocument(); + expect(syntaxHighlighter).toHaveAttribute('data-language', 'javascript'); + }); + + it('renders text language using SyntaxHighlighter', () => { + renderWithTheme( + This is plain text, + ); + + const syntaxHighlighter = screen.getByTestId('syntax-highlighter'); + expect(syntaxHighlighter).toBeInTheDocument(); + expect(syntaxHighlighter).toHaveAttribute('data-language', 'text'); + }); + + it('renders inline code when no language is specified', () => { + renderWithTheme(Some code without language); + + const codeElement = screen + .getByText(/Some code without language/) + .closest('code'); + expect(codeElement).toBeInTheDocument(); + expect(codeElement?.tagName).toBe('CODE'); + }); +}); diff --git a/apps/web/src/components/ui/__tests__/SyntaxHighlighter.test.tsx b/apps/web/src/components/ui/__tests__/SyntaxHighlighter.test.tsx new file mode 100644 index 0000000000..72dc78ca9e --- /dev/null +++ b/apps/web/src/components/ui/__tests__/SyntaxHighlighter.test.tsx @@ -0,0 +1,163 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { ThemeProvider } from 'next-themes'; +import { SyntaxHighlighter } from '../SyntaxHighlighter'; + +// Mock shiki +vi.mock('shiki', () => ({ + createHighlighter: vi.fn().mockResolvedValue({ + getLoadedLanguages: vi + .fn() + .mockReturnValue(['javascript', 'typescript', 'python']), + loadLanguage: vi.fn().mockResolvedValue(undefined), + codeToHtml: vi.fn().mockImplementation((code, options) => { + return `
${code}
`; + }), + }), +})); + +const renderWithTheme = (component: React.ReactElement, theme = 'light') => { + return render( + + {component} + , + ); +}; + +describe('SyntaxHighlighter', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders loading state initially', () => { + renderWithTheme( + , + ); + + // Should show loading state with plain code + const codeElement = screen.getByText("console.log('test');"); + expect(codeElement).toBeInTheDocument(); + expect(codeElement).toHaveClass('opacity-70'); // Loading state styling + }); + + it('renders highlighted code after loading', async () => { + renderWithTheme( + , + ); + + // Wait for the highlighting to complete + await waitFor(() => { + const highlightedElement = screen.getByText("console.log('test');"); + expect(highlightedElement).toBeInTheDocument(); + }); + }); + + it('handles unsupported languages gracefully', async () => { + renderWithTheme( + , + ); + + await waitFor(() => { + const codeElement = screen.getByText('some code'); + expect(codeElement).toBeInTheDocument(); + }); + }); + + it('applies correct theme based on theme provider', async () => { + renderWithTheme( + , + 'dark', + ); + + await waitFor(() => { + const codeElement = screen.getByText('const x = 1;'); + expect(codeElement).toBeInTheDocument(); + }); + }); + + it('handles empty code', async () => { + renderWithTheme(); + + await waitFor(() => { + // Should render without errors - check for the container div + const container = document.querySelector('.bg-muted'); + expect(container).toBeInTheDocument(); + }); + }); + + it('applies custom className', async () => { + renderWithTheme( + , + ); + + await waitFor(() => { + const container = document.querySelector('.custom-class'); + expect(container).toBeInTheDocument(); + expect(container).toHaveClass('custom-class'); + }); + }); + + it('falls back to plain code on error', async () => { + // Mock an error in the highlighter + const mockCreateHighlighter = vi + .fn() + .mockRejectedValue(new Error('Highlighting failed')); + vi.doMock('shiki', () => ({ + createHighlighter: mockCreateHighlighter, + })); + + renderWithTheme( + , + ); + + await waitFor(() => { + const codeElement = screen.getByText('error code'); + expect(codeElement).toBeInTheDocument(); + // Should be in a pre/code fallback structure + expect(codeElement.closest('pre')).toBeInTheDocument(); + }); + }); + + it('handles different programming languages', async () => { + const languages = ['python', 'typescript', 'java']; + + for (const lang of languages) { + const { unmount } = renderWithTheme( + , + ); + + await waitFor(() => { + const codeElement = screen.getByText(`// ${lang} code`); + expect(codeElement).toBeInTheDocument(); + }); + + unmount(); + } + }); + + it('caches highlighter instance for performance', async () => { + // Render multiple instances + const { unmount: unmount1 } = renderWithTheme( + , + ); + + const { unmount: unmount2 } = renderWithTheme( + , + ); + + await waitFor(() => { + expect(screen.getByText('code1')).toBeInTheDocument(); + expect(screen.getByText('code2')).toBeInTheDocument(); + }); + + // Both components should render successfully + expect(screen.getByText('code1')).toBeInTheDocument(); + expect(screen.getByText('code2')).toBeInTheDocument(); + + unmount1(); + unmount2(); + }); +}); diff --git a/apps/web/vitest.setup.client.ts b/apps/web/vitest.setup.client.ts index dbe35ded60..02b68c099b 100644 --- a/apps/web/vitest.setup.client.ts +++ b/apps/web/vitest.setup.client.ts @@ -12,4 +12,19 @@ global.IntersectionObserver = vi.fn().mockImplementation(() => ({ disconnect: vi.fn(), })); +// Mock matchMedia for next-themes +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), // deprecated + removeListener: vi.fn(), // deprecated + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); + beforeEach(() => vi.clearAllMocks()); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ba1fde6d5..e17d22f110 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -302,6 +302,9 @@ importers: require-in-the-middle: specifier: ^7.5.2 version: 7.5.2 + shiki: + specifier: ^3.7.0 + version: 3.7.0 sonner: specifier: ^2.0.3 version: 2.0.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -2451,6 +2454,27 @@ packages: peerDependencies: webpack: '>=4.40.0' + '@shikijs/core@3.7.0': + resolution: {integrity: sha512-yilc0S9HvTPyahHpcum8eonYrQtmGTU0lbtwxhA6jHv4Bm1cAdlPFRCJX4AHebkCm75aKTjjRAW+DezqD1b/cg==} + + '@shikijs/engine-javascript@3.7.0': + resolution: {integrity: sha512-0t17s03Cbv+ZcUvv+y33GtX75WBLQELgNdVghnsdhTgU3hVcWcMsoP6Lb0nDTl95ZJfbP1mVMO0p3byVh3uuzA==} + + '@shikijs/engine-oniguruma@3.7.0': + resolution: {integrity: sha512-5BxcD6LjVWsGu4xyaBC5bu8LdNgPCVBnAkWTtOCs/CZxcB22L8rcoWfv7Hh/3WooVjBZmFtyxhgvkQFedPGnFw==} + + '@shikijs/langs@3.7.0': + resolution: {integrity: sha512-1zYtdfXLr9xDKLTGy5kb7O0zDQsxXiIsw1iIBcNOO8Yi5/Y1qDbJ+0VsFoqTlzdmneO8Ij35g7QKF8kcLyznCQ==} + + '@shikijs/themes@3.7.0': + resolution: {integrity: sha512-VJx8497iZPy5zLiiCTSIaOChIcKQwR0FebwE9S3rcN0+J/GTWwQ1v/bqhTbpbY3zybPKeO8wdammqkpXc4NVjQ==} + + '@shikijs/types@3.7.0': + resolution: {integrity: sha512-MGaLeaRlSWpnP0XSAum3kP3a8vtcTsITqoEPYdt3lQG3YCdQH4DnEhodkYcNMcU0uW0RffhoD1O3e0vG5eSBBg==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@sindresorhus/merge-streams@4.0.0': resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} @@ -4345,6 +4369,9 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} @@ -4371,6 +4398,9 @@ packages: html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + http-errors@2.0.0: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} @@ -5359,6 +5389,12 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} + oniguruma-parser@0.12.1: + resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==} + + oniguruma-to-es@4.3.3: + resolution: {integrity: sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -5844,6 +5880,15 @@ packages: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.0.1: + resolution: {integrity: sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==} + regexp.prototype.flags@1.5.4: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} @@ -6038,6 +6083,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shiki@3.7.0: + resolution: {integrity: sha512-ZcI4UT9n6N2pDuM2n3Jbk0sR4Swzq43nLPgS/4h0E3B/NrFn2HKElrDtceSf8Zx/OWYOo7G1SAtBLypCp+YXqg==} + shimmer@1.2.1: resolution: {integrity: sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==} @@ -8809,6 +8857,39 @@ snapshots: - encoding - supports-color + '@shikijs/core@3.7.0': + dependencies: + '@shikijs/types': 3.7.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@3.7.0': + dependencies: + '@shikijs/types': 3.7.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.3 + + '@shikijs/engine-oniguruma@3.7.0': + dependencies: + '@shikijs/types': 3.7.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@3.7.0': + dependencies: + '@shikijs/types': 3.7.0 + + '@shikijs/themes@3.7.0': + dependencies: + '@shikijs/types': 3.7.0 + + '@shikijs/types@3.7.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + '@sindresorhus/merge-streams@4.0.0': {} '@standard-schema/utils@0.3.0': {} @@ -10965,6 +11046,20 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.7 @@ -11007,6 +11102,8 @@ snapshots: html-url-attributes@3.0.1: {} + html-void-elements@3.0.0: {} + http-errors@2.0.0: dependencies: depd: 2.0.0 @@ -12150,6 +12247,14 @@ snapshots: dependencies: mimic-function: 5.0.1 + oniguruma-parser@0.12.1: {} + + oniguruma-to-es@4.3.3: + dependencies: + oniguruma-parser: 0.12.1 + regex: 6.0.1 + regex-recursion: 6.0.2 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -12676,6 +12781,16 @@ snapshots: get-proto: 1.0.1 which-builtin-type: 1.2.1 + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.0.1: + dependencies: + regex-utilities: 2.3.0 + regexp.prototype.flags@1.5.4: dependencies: call-bind: 1.0.8 @@ -12976,6 +13091,17 @@ snapshots: shebang-regex@3.0.0: {} + shiki@3.7.0: + dependencies: + '@shikijs/core': 3.7.0 + '@shikijs/engine-javascript': 3.7.0 + '@shikijs/engine-oniguruma': 3.7.0 + '@shikijs/langs': 3.7.0 + '@shikijs/themes': 3.7.0 + '@shikijs/types': 3.7.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + shimmer@1.2.1: {} side-channel-list@1.0.0: