diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6e32145..fff1aec4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,6 +166,9 @@ importers: videojs-youtube: specifier: ^3.0.1 version: 3.0.1(video.js@8.23.7) + web-push: + specifier: ^3.6.7 + version: 3.6.7 web-vitals: specifier: ^4.2.4 version: 4.2.4 @@ -3662,6 +3665,9 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + asn1.js@5.4.1: + resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -3906,6 +3912,9 @@ packages: buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-equals@1.0.4: resolution: {integrity: sha512-99MsCq0j5+RhubVEtKQgKaD6EM+UP3xJgIvQqwJ3SOLDUekzxMX1ylXBng+Wa2sh7mGT0W6RUly8ojjr1Tt6nA==} engines: {node: '>=0.10.0'} @@ -4353,6 +4362,9 @@ packages: duplexify@4.1.3: resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ecpair@2.1.0: resolution: {integrity: sha512-cL/mh3MtJutFOvFc27GPZE2pWL3a3k4YvzUWEOvilnfZVlH3Jwgx/7d6tlD7/75tNk8TG2m+7Kgtz0SI1tWcqw==} engines: {node: '>=8.0.0'} @@ -5009,6 +5021,10 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} + http_ece@1.2.0: + resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==} + engines: {node: '>=16'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -5489,6 +5505,12 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -7588,6 +7610,11 @@ packages: resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} engines: {node: '>=10.13.0'} + web-push@3.6.7: + resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==} + engines: {node: '>= 16'} + hasBin: true + web-vitals@4.2.4: resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==} @@ -11935,6 +11962,13 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + asn1.js@5.4.1: + dependencies: + bn.js: 4.12.3 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + safer-buffer: 2.1.2 + assertion-error@2.0.1: {} ast-types-flow@0.0.8: {} @@ -12151,8 +12185,7 @@ snapshots: blakejs@1.2.1: {} - bn.js@4.12.3: - optional: true + bn.js@4.12.3: {} brace-expansion@1.1.15: dependencies: @@ -12224,6 +12257,8 @@ snapshots: buffer-crc32@0.2.13: {} + buffer-equal-constant-time@1.0.1: {} + buffer-equals@1.0.4: optional: true @@ -12723,6 +12758,10 @@ snapshots: readable-stream: 3.6.2 stream-shift: 1.0.3 + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ecpair@2.1.0: dependencies: randombytes: 2.1.0 @@ -13603,6 +13642,8 @@ snapshots: transitivePeerDependencies: - supports-color + http_ece@1.2.0: {} + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -14277,6 +14318,17 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -14503,8 +14555,7 @@ snapshots: min-indent@1.0.1: {} - minimalistic-assert@1.0.1: - optional: true + minimalistic-assert@1.0.1: {} minimalistic-crypto-utils@1.0.1: optional: true @@ -16657,6 +16708,16 @@ snapshots: glob-to-regexp: 0.4.1 graceful-fs: 4.2.11 + web-push@3.6.7: + dependencies: + asn1.js: 5.4.1 + http_ece: 1.2.0 + https-proxy-agent: 7.0.6 + jws: 4.0.1 + minimist: 1.2.8 + transitivePeerDependencies: + - supports-color + web-vitals@4.2.4: {} webdriver-bidi-protocol@0.4.1: {} diff --git a/src/components/code/CodeBlock.tsx b/src/components/code/CodeBlock.tsx new file mode 100644 index 00000000..3e1911c7 --- /dev/null +++ b/src/components/code/CodeBlock.tsx @@ -0,0 +1,125 @@ +'use client'; + +import React, { useState, useRef } from 'react'; +import { Copy, Check, ChevronDown, ChevronUp } from 'lucide-react'; +import { SyntaxHighlighter } from './SyntaxHighlighter'; + +interface CodeBlockProps { + code: string; + language?: string; + /** Max visible lines before collapse toggle appears. Default: 15 */ + collapseThreshold?: number; + className?: string; +} + +export const CodeBlock: React.FC = ({ + code, + language = 'javascript', + collapseThreshold = 15, + className = '', +}) => { + const [copied, setCopied] = useState(false); + const [expanded, setExpanded] = useState(false); + const timeoutRef = useRef | null>(null); + + const lineCount = code.split('\n').length; + const isCollapsible = lineCount > collapseThreshold; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(code); + setCopied(true); + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setCopied(false), 2000); + } catch { + // clipboard unavailable — silently ignore + } + }; + + return ( +
+ {/* Header */} +
+ + + {/* Copy button */} + +
+ + {/* Code area with expand/collapse */} +
+
+          {code}
+        
+
+ + {/* Expand / Collapse toggle */} + {isCollapsible && ( + + )} +
+ ); +}; diff --git a/src/components/code/__tests__/CodeBlock.test.tsx b/src/components/code/__tests__/CodeBlock.test.tsx new file mode 100644 index 00000000..f6fc9059 --- /dev/null +++ b/src/components/code/__tests__/CodeBlock.test.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { CodeBlock } from '../CodeBlock'; + +// Mock SyntaxHighlighter to keep tests focused on CodeBlock behaviour +vi.mock('../SyntaxHighlighter', () => ({ + SyntaxHighlighter: ({ language }: { language: string }) => ( + {language} + ), +})); + +const SHORT_CODE = 'const x = 1;'; +const LONG_CODE = Array.from({ length: 20 }, (_, i) => `const line${i} = ${i};`).join('\n'); + +describe('CodeBlock', () => { + beforeEach(() => { + Object.assign(navigator, { + clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, + }); + }); + + it('renders code content', () => { + render(); + expect(screen.getByText(SHORT_CODE)).toBeInTheDocument(); + }); + + it('renders language badge via SyntaxHighlighter', () => { + render(); + expect(screen.getByTestId('syntax-highlighter')).toHaveTextContent('typescript'); + }); + + it('does not show expand toggle for short code', () => { + render(); + expect(screen.queryByRole('button', { name: /show/i })).not.toBeInTheDocument(); + }); + + it('shows expand toggle when line count exceeds collapseThreshold', () => { + render(); + expect(screen.getByRole('button', { name: /show \d+ more line/i })).toBeInTheDocument(); + }); + + it('toggles expanded state on expand button click', () => { + render(); + const toggle = screen.getByRole('button', { name: /show \d+ more line/i }); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(toggle); + expect(screen.getByRole('button', { name: /collapse/i })).toHaveAttribute( + 'aria-expanded', + 'true', + ); + }); + + it('copies code to clipboard and shows Copied! feedback', async () => { + render(); + const copyBtn = screen.getByRole('button', { name: /copy code/i }); + fireEvent.click(copyBtn); + await waitFor(() => + expect(screen.getByRole('button', { name: /copied!/i })).toBeInTheDocument(), + ); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(SHORT_CODE); + }); + + it('applies custom className to wrapper', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('my-custom-class'); + }); + + it('uses collapseThreshold prop for max-height style', () => { + const { container } = render( + , + ); + const codeArea = container.querySelector('[style*="max-height"]') as HTMLElement; + expect(codeArea.style.maxHeight).toBe(`${10 * 1.625}rem`); + }); + + it('removes max-height constraint when expanded', () => { + const { container } = render( + , + ); + const toggle = screen.getByRole('button', { name: /show \d+ more line/i }); + fireEvent.click(toggle); + const codeArea = container.querySelector('[style*="max-height"]') as HTMLElement; + expect(codeArea.style.maxHeight).toBe('9999px'); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index d6718f8c..76ba1879 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.base.json", "compilerOptions": { - "ignoreDeprecations": "6.0", + "ignoreDeprecations": "5.0", "baseUrl": ".", "paths": { "@/*": [