CodeBlock.tsx278 lines · main
| 1 | 'use client' |
| 2 | |
| 3 | // @ts-ignore |
| 4 | import curl from 'highlightjs-curl' |
| 5 | import { noop } from 'lodash' |
| 6 | import { Check, Copy } from 'lucide-react' |
| 7 | import { useTheme } from 'next-themes' |
| 8 | import { Children, ReactNode, useState } from 'react' |
| 9 | import { Light as SyntaxHighlighter, SyntaxHighlighterProps } from 'react-syntax-highlighter' |
| 10 | import bash from 'react-syntax-highlighter/dist/cjs/languages/hljs/bash' |
| 11 | import csharp from 'react-syntax-highlighter/dist/cjs/languages/hljs/csharp' |
| 12 | import dart from 'react-syntax-highlighter/dist/cjs/languages/hljs/dart' |
| 13 | import go from 'react-syntax-highlighter/dist/cjs/languages/hljs/go' |
| 14 | import http from 'react-syntax-highlighter/dist/cjs/languages/hljs/http' |
| 15 | import ini from 'react-syntax-highlighter/dist/cjs/languages/hljs/ini' |
| 16 | import js from 'react-syntax-highlighter/dist/cjs/languages/hljs/javascript' |
| 17 | import json from 'react-syntax-highlighter/dist/cjs/languages/hljs/json' |
| 18 | import kotlin from 'react-syntax-highlighter/dist/cjs/languages/hljs/kotlin' |
| 19 | import pgsql from 'react-syntax-highlighter/dist/cjs/languages/hljs/pgsql' |
| 20 | import php from 'react-syntax-highlighter/dist/cjs/languages/hljs/php' |
| 21 | import { |
| 22 | default as py, |
| 23 | default as python, |
| 24 | } from 'react-syntax-highlighter/dist/cjs/languages/hljs/python' |
| 25 | import sql from 'react-syntax-highlighter/dist/cjs/languages/hljs/sql' |
| 26 | import swift from 'react-syntax-highlighter/dist/cjs/languages/hljs/swift' |
| 27 | import ts from 'react-syntax-highlighter/dist/cjs/languages/hljs/typescript' |
| 28 | import xml from 'react-syntax-highlighter/dist/cjs/languages/hljs/xml' |
| 29 | import yaml from 'react-syntax-highlighter/dist/cjs/languages/hljs/yaml' |
| 30 | import { Button, cn, copyToClipboard } from 'ui' |
| 31 | |
| 32 | import { monokaiCustomTheme } from './CodeBlock.utils' |
| 33 | |
| 34 | export type CodeBlockLang = |
| 35 | | 'js' |
| 36 | | 'jsx' |
| 37 | | 'sql' |
| 38 | | 'py' |
| 39 | | 'bash' |
| 40 | | 'ts' |
| 41 | | 'dart' |
| 42 | | 'json' |
| 43 | | 'csharp' |
| 44 | | 'kotlin' |
| 45 | | 'curl' |
| 46 | | 'http' |
| 47 | | 'php' |
| 48 | | 'python' |
| 49 | | 'go' |
| 50 | | 'pgsql' |
| 51 | | 'swift' |
| 52 | | 'yaml' |
| 53 | | 'toml' |
| 54 | | 'html' |
| 55 | |
| 56 | export interface CodeBlockProps { |
| 57 | title?: ReactNode |
| 58 | language?: CodeBlockLang |
| 59 | linesToHighlight?: number[] |
| 60 | highlightBorder?: boolean |
| 61 | styleConfig?: { |
| 62 | lineNumber?: string |
| 63 | highlightBackgroundColor?: string |
| 64 | highlightBorderColor?: string |
| 65 | } |
| 66 | hideCopy?: boolean |
| 67 | hideLineNumbers?: boolean |
| 68 | className?: string |
| 69 | wrapperClassName?: string |
| 70 | value?: string |
| 71 | theme?: any |
| 72 | children?: string |
| 73 | wrapLines?: boolean |
| 74 | focusable?: boolean |
| 75 | renderer?: SyntaxHighlighterProps['renderer'] |
| 76 | handleCopy?: (value?: string) => void |
| 77 | onCopyCallback?: () => void |
| 78 | } |
| 79 | |
| 80 | /** |
| 81 | * CodeBlock component for displaying syntax-highlighted code. |
| 82 | * @param {ReactNode} [props.title] - Optional title for the code block. |
| 83 | * @param {string} [props.language] - The programming language of the code. |
| 84 | * @param {number[]} [props.linesToHighlight=[]] - Array of line numbers to highlight. |
| 85 | * @param {boolean} [props.highlightBorder] - Whether to show a border on highlighted lines. |
| 86 | * @param {Object} [props.styleConfig] - Custom style configurations. |
| 87 | * @param {string} [props.className] - Additional CSS classes for the code block. |
| 88 | * @param {string} [props.wrapperClassName] - CSS classes for the wrapper div. |
| 89 | * @param {string} [props.value] - The code content as a string. |
| 90 | * @param {any} [props.theme] - Custom theme for syntax highlighting. |
| 91 | * @param {string} [props.children] - The code content as children. |
| 92 | * @param {boolean} [props.hideCopy=false] - Whether to hide the copy button. |
| 93 | * @param {boolean} [props.hideLineNumbers=false] - Whether to hide line numbers. |
| 94 | * @param {SyntaxHighlighterProps['renderer']} [props.renderer] - Custom renderer for syntax highlighting. |
| 95 | * @param {boolean} [props.focusable=true] - Whether the code block is focusable. When true, users can focus the code block to select text or use ⌘A (Cmd+A) to select all. This is so we don't need to load Monaco Editor. |
| 96 | * @param {function} [props.handleCopy] - Optional override behaviour for copying value. For e.g if the code block contains obfuscated values, but the copy behaviour should reveal those values instead. |
| 97 | */ |
| 98 | export const CodeBlock = ({ |
| 99 | title, |
| 100 | language, |
| 101 | linesToHighlight = [], |
| 102 | highlightBorder, |
| 103 | styleConfig, |
| 104 | className, |
| 105 | wrapperClassName, |
| 106 | value, |
| 107 | theme, |
| 108 | children, |
| 109 | hideCopy = false, |
| 110 | hideLineNumbers = false, |
| 111 | wrapLines = true, |
| 112 | renderer, |
| 113 | focusable = true, |
| 114 | onCopyCallback = noop, |
| 115 | handleCopy, |
| 116 | }: CodeBlockProps) => { |
| 117 | const { resolvedTheme } = useTheme() |
| 118 | const isDarkTheme = resolvedTheme?.includes('dark')! |
| 119 | const monokaiTheme = theme ?? monokaiCustomTheme(isDarkTheme) |
| 120 | |
| 121 | const [copied, setCopied] = useState(false) |
| 122 | |
| 123 | const onSelectCopy = (value?: string) => { |
| 124 | if (value) { |
| 125 | if (!!handleCopy) { |
| 126 | handleCopy(value) |
| 127 | } else { |
| 128 | copyToClipboard(value) |
| 129 | } |
| 130 | } |
| 131 | setCopied(true) |
| 132 | onCopyCallback() |
| 133 | setTimeout(() => setCopied(false), 1000) |
| 134 | } |
| 135 | |
| 136 | // Extract string when `children` has a single string node |
| 137 | const childrenArray = Children.toArray(children) |
| 138 | const [singleChild] = childrenArray.length === 1 ? childrenArray : [] |
| 139 | const singleString = typeof singleChild === 'string' ? singleChild : undefined |
| 140 | |
| 141 | let codeValue = value ?? singleString ?? children |
| 142 | codeValue = codeValue?.trimEnd?.() ?? codeValue |
| 143 | |
| 144 | // check the length of the string inside the <code> tag |
| 145 | // if it's fewer than 70 characters, add a white-space: pre so it doesn't wrap |
| 146 | const shortCodeBlockClasses = |
| 147 | typeof codeValue === 'string' && codeValue.length < 70 ? 'short-inline-codeblock' : '' |
| 148 | |
| 149 | let lang = language ? language : className ? className.replace('language-', '') : 'js' |
| 150 | // force jsx to be js highlighted |
| 151 | if (lang === 'jsx') lang = 'js' |
| 152 | SyntaxHighlighter.registerLanguage('js', js) |
| 153 | SyntaxHighlighter.registerLanguage('ts', ts) |
| 154 | SyntaxHighlighter.registerLanguage('py', py) |
| 155 | SyntaxHighlighter.registerLanguage('sql', sql) |
| 156 | SyntaxHighlighter.registerLanguage('bash', bash) |
| 157 | SyntaxHighlighter.registerLanguage('dart', dart) |
| 158 | SyntaxHighlighter.registerLanguage('csharp', csharp) |
| 159 | SyntaxHighlighter.registerLanguage('json', json) |
| 160 | SyntaxHighlighter.registerLanguage('kotlin', kotlin) |
| 161 | SyntaxHighlighter.registerLanguage('curl', curl) |
| 162 | SyntaxHighlighter.registerLanguage('http', http) |
| 163 | SyntaxHighlighter.registerLanguage('php', php) |
| 164 | SyntaxHighlighter.registerLanguage('python', python) |
| 165 | SyntaxHighlighter.registerLanguage('go', go) |
| 166 | SyntaxHighlighter.registerLanguage('pgsql', pgsql) |
| 167 | SyntaxHighlighter.registerLanguage('swift', swift) |
| 168 | SyntaxHighlighter.registerLanguage('html', xml) |
| 169 | SyntaxHighlighter.registerLanguage('toml', ini) |
| 170 | SyntaxHighlighter.registerLanguage('yaml', yaml) |
| 171 | |
| 172 | const large = false |
| 173 | // don't show line numbers if bash == lang |
| 174 | if (lang === 'bash' || lang === 'sh') hideLineNumbers = true |
| 175 | const showLineNumbers = !hideLineNumbers |
| 176 | |
| 177 | return ( |
| 178 | <> |
| 179 | {title && ( |
| 180 | <div className="text-sm rounded-t-md bg-surface-100 py-2 px-4 border border-b-0 border-default font-sans"> |
| 181 | {title} |
| 182 | </div> |
| 183 | )} |
| 184 | {className ? ( |
| 185 | <div |
| 186 | className={cn( |
| 187 | 'group relative max-w-[90vw] md:max-w-none overflow-auto', |
| 188 | wrapperClassName |
| 189 | )} |
| 190 | > |
| 191 | {/* @ts-ignore */} |
| 192 | <SyntaxHighlighter |
| 193 | suppressContentEditableWarning |
| 194 | language={lang} |
| 195 | wrapLines={wrapLines} |
| 196 | style={monokaiTheme} |
| 197 | className={cn( |
| 198 | 'code-block border border-surface p-4 w-full my-0! !bg-surface-100 outline-hidden focus:border-foreground-lighter/50', |
| 199 | `${!title ? 'rounded-md' : 'rounded-t-none rounded-b-md'}`, |
| 200 | `${!showLineNumbers ? 'pl-6' : ''}`, |
| 201 | className |
| 202 | )} |
| 203 | customStyle={{ |
| 204 | fontSize: large ? 18 : 13, |
| 205 | lineHeight: large ? 1.5 : 1.4, |
| 206 | }} |
| 207 | showLineNumbers={showLineNumbers} |
| 208 | lineProps={(lineNumber) => { |
| 209 | if (linesToHighlight.includes(lineNumber)) { |
| 210 | return { |
| 211 | style: { |
| 212 | display: 'block', |
| 213 | backgroundColor: styleConfig?.highlightBackgroundColor |
| 214 | ? styleConfig?.highlightBackgroundColor |
| 215 | : 'hsl(var(--background-selection))', |
| 216 | borderLeft: highlightBorder |
| 217 | ? `1px solid ${styleConfig?.highlightBorderColor ? styleConfig?.highlightBorderColor : 'hsl(var(--foreground-default)'})` |
| 218 | : null, |
| 219 | }, |
| 220 | class: 'hljs-line-highlight', |
| 221 | } |
| 222 | } |
| 223 | return {} |
| 224 | }} |
| 225 | lineNumberContainerStyle={{ |
| 226 | paddingTop: '128px', |
| 227 | }} |
| 228 | lineNumberStyle={{ |
| 229 | minWidth: '44px', |
| 230 | paddingLeft: '4px', |
| 231 | paddingRight: '4px', |
| 232 | marginRight: '12px', |
| 233 | color: styleConfig?.lineNumber ?? '#828282', |
| 234 | textAlign: 'center', |
| 235 | fontSize: large ? 14 : 12, |
| 236 | paddingTop: '4px', |
| 237 | paddingBottom: '4px', |
| 238 | }} |
| 239 | renderer={renderer} |
| 240 | contentEditable={focusable} |
| 241 | onBeforeInput={(e: any) => { |
| 242 | e.preventDefault() |
| 243 | return false |
| 244 | }} |
| 245 | onKeyDown={(e: any) => { |
| 246 | if (e.code === 'Backspace') { |
| 247 | e.preventDefault() |
| 248 | return false |
| 249 | } |
| 250 | }} |
| 251 | > |
| 252 | {codeValue} |
| 253 | </SyntaxHighlighter> |
| 254 | {!hideCopy && (value || children) && className ? ( |
| 255 | <div |
| 256 | className={[ |
| 257 | 'absolute right-2 top-2', |
| 258 | 'opacity-0 group-hover:opacity-100 transition', |
| 259 | `${isDarkTheme ? 'dark' : ''}`, |
| 260 | ].join(' ')} |
| 261 | > |
| 262 | <Button |
| 263 | type="default" |
| 264 | className="px-1.5" |
| 265 | icon={copied ? <Check /> : <Copy />} |
| 266 | onClick={() => onSelectCopy(value || children)} |
| 267 | > |
| 268 | {copied ? 'Copied' : ''} |
| 269 | </Button> |
| 270 | </div> |
| 271 | ) : null} |
| 272 | </div> |
| 273 | ) : ( |
| 274 | <code className={shortCodeBlockClasses}>{value || children}</code> |
| 275 | )} |
| 276 | </> |
| 277 | ) |
| 278 | } |