diff --git a/packages/ui-patterns/package.json b/packages/ui-patterns/package.json index 05fbd36960b..33465589fc3 100644 --- a/packages/ui-patterns/package.json +++ b/packages/ui-patterns/package.json @@ -582,10 +582,6 @@ "import": "./src/admonition.tsx", "types": "./src/admonition.tsx" }, - "./multiple-code-block": { - "import": "./src/multiple-code-block.tsx", - "types": "./src/multiple-code-block.tsx" - }, "./consent": { "import": "./src/consent.tsx", "types": "./src/consent.tsx" diff --git a/packages/ui-patterns/src/multiple-code-block.tsx b/packages/ui-patterns/src/multiple-code-block.tsx deleted file mode 100644 index 87d79085566..00000000000 --- a/packages/ui-patterns/src/multiple-code-block.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { - CodeBlock, - CodeBlockLang, - TabsContent_Shadcn_, - TabsList_Shadcn_, - TabsTrigger_Shadcn_, - Tabs_Shadcn_, -} from 'ui' - -interface MultipleCodeBlockFile { - name: string - code: string - language?: string -} - -interface MultipleCodeBlockProps { - files: MultipleCodeBlockFile[] - value?: string - onValueChange?: (value: string) => void -} - -const languageAliases: Record = { - bash: 'bash', - csharp: 'csharp', - cs: 'csharp', - curl: 'curl', - dart: 'dart', - go: 'go', - html: 'html', - http: 'http', - javascript: 'js', - js: 'js', - json: 'json', - jsx: 'jsx', - kotlin: 'kotlin', - pgsql: 'pgsql', - php: 'php', - py: 'python', - python: 'python', - sh: 'bash', - shell: 'bash', - sql: 'sql', - swift: 'swift', - ts: 'ts', - typescript: 'ts', - yaml: 'yaml', - yml: 'yaml', -} - -const extensionLanguageMap: Record = { - astro: 'html', - bash: 'bash', - cjs: 'js', - dart: 'dart', - go: 'go', - htm: 'html', - html: 'html', - js: 'js', - json: 'json', - jsx: 'jsx', - kt: 'kotlin', - mjs: 'js', - php: 'php', - pgsql: 'pgsql', - py: 'python', - sh: 'bash', - sql: 'sql', - swift: 'swift', - svelte: 'html', - ts: 'ts', - vue: 'html', - yaml: 'yaml', - yml: 'yaml', -} - -const inferLanguageFromName = (name: string): CodeBlockLang | undefined => { - const lowerName = name.toLowerCase() - if (lowerName.startsWith('.env')) { - return 'bash' - } - - const extension = lowerName.split('.').pop() - if (!extension || extension === lowerName) { - return undefined - } - - return extensionLanguageMap[extension] -} - -const resolveLanguage = (language: string | undefined, name: string): CodeBlockLang => { - if (language) { - const normalized = language.toLowerCase() - const resolved = languageAliases[normalized] - if (resolved) { - return resolved - } - } - - return inferLanguageFromName(name) ?? 'js' -} - -export const MultipleCodeBlock = ({ files, value, onValueChange }: MultipleCodeBlockProps) => { - if (!files?.length) { - return null - } - - const defaultValue = files[0]?.name ?? '' - - const trimmedFiles = files.map((file) => ({ - ...file, - code: typeof file.code === 'string' ? file.code.trim() : file.code, - })) - - return ( - - - {files.map((file) => ( - - {file.name} - - ))} - - - {trimmedFiles.map((file) => ( - - - - ))} - - ) -} diff --git a/packages/ui/src/components/CodeBlock/CodeBlock.tsx b/packages/ui/src/components/CodeBlock/CodeBlock.tsx index 1b56a07d8fb..7b315140229 100644 --- a/packages/ui/src/components/CodeBlock/CodeBlock.tsx +++ b/packages/ui/src/components/CodeBlock/CodeBlock.tsx @@ -1,275 +1,201 @@ -'use client' +import { Fragment, type PropsWithChildren } from 'react' +import { type BundledLanguage, type ThemedToken, bundledLanguages, createHighlighter } from 'shiki' +import { type ExtraFiles, type NodeHover, createTwoslasher } from 'twoslash' +import { cn } from 'ui' -import curl from 'highlightjs-curl' -import { noop } from 'lodash' -import { Check, Copy } from 'lucide-react' -import { useTheme } from 'next-themes' -import { Children, ReactNode, useState } from 'react' -import { Light as SyntaxHighlighter, SyntaxHighlighterProps } from 'react-syntax-highlighter' -import bash from 'react-syntax-highlighter/dist/cjs/languages/hljs/bash' -import csharp from 'react-syntax-highlighter/dist/cjs/languages/hljs/csharp' -import dart from 'react-syntax-highlighter/dist/cjs/languages/hljs/dart' -import go from 'react-syntax-highlighter/dist/cjs/languages/hljs/go' -import http from 'react-syntax-highlighter/dist/cjs/languages/hljs/http' -import js from 'react-syntax-highlighter/dist/cjs/languages/hljs/javascript' -import json from 'react-syntax-highlighter/dist/cjs/languages/hljs/json' -import kotlin from 'react-syntax-highlighter/dist/cjs/languages/hljs/kotlin' -import pgsql from 'react-syntax-highlighter/dist/cjs/languages/hljs/pgsql' -import php from 'react-syntax-highlighter/dist/cjs/languages/hljs/php' -import { - default as py, - default as python, -} from 'react-syntax-highlighter/dist/cjs/languages/hljs/python' -import sql from 'react-syntax-highlighter/dist/cjs/languages/hljs/sql' -import swift from 'react-syntax-highlighter/dist/cjs/languages/hljs/swift' -import ts from 'react-syntax-highlighter/dist/cjs/languages/hljs/typescript' -import yaml from 'react-syntax-highlighter/dist/cjs/languages/hljs/yaml' +import { AnnotatedSpan, CodeBlockControls } from './CodeBlock.client' +import { getFontStyle } from './CodeBlock.utils' +import theme from './supabase-2.json' with { type: 'json' } +import denoTypes from './types/lib.deno.d.ts.include' -import { copyToClipboard } from '../../lib/utils' -import { cn } from '../../lib/utils/cn' -import { Button } from '../Button/Button' -import { monokaiCustomTheme } from './CodeBlock.utils' +const extraFiles: ExtraFiles = { 'deno.d.ts': denoTypes } -export type CodeBlockLang = - | 'js' - | 'jsx' - | 'sql' - | 'py' - | 'bash' - | 'ts' - | 'dart' - | 'json' - | 'csharp' - | 'kotlin' - | 'curl' - | 'http' - | 'php' - | 'python' - | 'go' - | 'pgsql' - | 'swift' - | 'yaml' - | 'toml' - | 'html' +const twoslasher = createTwoslasher({ extraFiles }) +const TWOSLASHABLE_LANGS: ReadonlyArray = ['js', 'ts', 'javascript', 'typescript'] -export interface CodeBlockProps { - title?: ReactNode - language?: CodeBlockLang - linesToHighlight?: number[] - highlightBorder?: boolean - styleConfig?: { - lineNumber?: string - highlightBackgroundColor?: string - highlightBorderColor?: string - } - hideCopy?: boolean - hideLineNumbers?: boolean - className?: string - wrapperClassName?: string - value?: string - theme?: any - children?: string - wrapLines?: boolean - focusable?: boolean - renderer?: SyntaxHighlighterProps['renderer'] - handleCopy?: (value?: string) => void - onCopyCallback?: () => void -} +const BUNDLED_LANGUAGES = Object.keys(bundledLanguages) +const highlighter = await createHighlighter({ + themes: [theme], + langs: BUNDLED_LANGUAGES, +}) -/** - * CodeBlock component for displaying syntax-highlighted code. - * @param {ReactNode} [props.title] - Optional title for the code block. - * @param {string} [props.language] - The programming language of the code. - * @param {number[]} [props.linesToHighlight=[]] - Array of line numbers to highlight. - * @param {boolean} [props.highlightBorder] - Whether to show a border on highlighted lines. - * @param {Object} [props.styleConfig] - Custom style configurations. - * @param {string} [props.className] - Additional CSS classes for the code block. - * @param {string} [props.wrapperClassName] - CSS classes for the wrapper div. - * @param {string} [props.value] - The code content as a string. - * @param {any} [props.theme] - Custom theme for syntax highlighting. - * @param {string} [props.children] - The code content as children. - * @param {boolean} [props.hideCopy=false] - Whether to hide the copy button. - * @param {boolean} [props.hideLineNumbers=false] - Whether to hide line numbers. - * @param {SyntaxHighlighterProps['renderer']} [props.renderer] - Custom renderer for syntax highlighting. - * @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. - * @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. - */ -export const CodeBlock = ({ - title, - language, - linesToHighlight = [], - highlightBorder, - styleConfig, +export async function CodeBlock({ className, - wrapperClassName, - value, - theme, + lang: langSetting, + lineNumbers = true, + contents, children, - hideCopy = false, - hideLineNumbers = false, - wrapLines = true, - renderer, - focusable = true, - onCopyCallback = noop, - handleCopy, -}: CodeBlockProps) => { - const { resolvedTheme } = useTheme() - const isDarkTheme = resolvedTheme?.includes('dark')! - const monokaiTheme = theme ?? monokaiCustomTheme(isDarkTheme) + skipTypeGeneration, +}: PropsWithChildren<{ + className?: string + lang?: string + lineNumbers?: boolean + contents?: string + skipTypeGeneration?: boolean +}>) { + let code = (contents || extractCode(children)).trim() + const lang = tryToBundledLanguage(langSetting || '') || extractLang(children) - const [copied, setCopied] = useState(false) - - const onSelectCopy = (value?: string) => { - if (value) { - if (!!handleCopy) { - handleCopy(value) - } else { - copyToClipboard(value) - } + let twoslashed = null as null | Map>> + if (!skipTypeGeneration && lang && TWOSLASHABLE_LANGS.includes(lang)) { + try { + const { code: editedCode, nodes } = twoslasher(code) + const hoverNodes: Array = nodes.filter((node) => node.type === 'hover') + twoslashed = annotationsByLine(hoverNodes) + code = editedCode + } catch (_err) { + // Silently ignore, if imports aren't defined type compilation fails + // Uncomment lines below to debug in dev + // console.log('\n==========CODE==========\n') + // console.log(code) + // console.error(_err.recommendation) } - setCopied(true) - onCopyCallback() - setTimeout(() => setCopied(false), 1000) } - // Extract string when `children` has a single string node - const childrenArray = Children.toArray(children) - const [singleChild] = childrenArray.length === 1 ? childrenArray : [] - const singleString = typeof singleChild === 'string' ? singleChild : undefined - - let codeValue = value ?? singleString ?? children - codeValue = codeValue?.trimEnd?.() ?? codeValue - - // check the length of the string inside the tag - // if it's fewer than 70 characters, add a white-space: pre so it doesn't wrap - const shortCodeBlockClasses = - typeof codeValue === 'string' && codeValue.length < 70 ? 'short-inline-codeblock' : '' - - let lang = language ? language : className ? className.replace('language-', '') : 'js' - // force jsx to be js highlighted - if (lang === 'jsx') lang = 'js' - SyntaxHighlighter.registerLanguage('js', js) - SyntaxHighlighter.registerLanguage('ts', ts) - SyntaxHighlighter.registerLanguage('py', py) - SyntaxHighlighter.registerLanguage('sql', sql) - SyntaxHighlighter.registerLanguage('bash', bash) - SyntaxHighlighter.registerLanguage('dart', dart) - SyntaxHighlighter.registerLanguage('csharp', csharp) - SyntaxHighlighter.registerLanguage('json', json) - SyntaxHighlighter.registerLanguage('kotlin', kotlin) - SyntaxHighlighter.registerLanguage('curl', curl) - SyntaxHighlighter.registerLanguage('http', http) - SyntaxHighlighter.registerLanguage('php', php) - SyntaxHighlighter.registerLanguage('python', python) - SyntaxHighlighter.registerLanguage('go', go) - SyntaxHighlighter.registerLanguage('pgsql', pgsql) - SyntaxHighlighter.registerLanguage('swift', swift) - SyntaxHighlighter.registerLanguage('yaml', yaml) - - const large = false - // don't show line numbers if bash == lang - if (lang === 'bash' || lang === 'sh') hideLineNumbers = true - const showLineNumbers = !hideLineNumbers + const { tokens } = highlighter.codeToTokens(code, { + lang: lang || undefined, + theme: 'Supabase Theme', + }) return ( - <> - {title && ( -
- {title} -
+
- {/* @ts-ignore */} - { - if (linesToHighlight.includes(lineNumber)) { - return { - style: { - display: 'block', - backgroundColor: styleConfig?.highlightBackgroundColor - ? styleConfig?.highlightBackgroundColor - : 'hsl(var(--background-selection))', - borderLeft: highlightBorder - ? `1px solid ${styleConfig?.highlightBorderColor ? styleConfig?.highlightBorderColor : 'hsl(var(--foreground-default)'})` - : null, - }, - class: 'hljs-line-highlight', - } - } - return {} - }} - lineNumberContainerStyle={{ - paddingTop: '128px', - }} - lineNumberStyle={{ - minWidth: '44px', - paddingLeft: '4px', - paddingRight: '4px', - marginRight: '12px', - color: styleConfig?.lineNumber ?? '#828282', - textAlign: 'center', - fontSize: large ? 14 : 12, - paddingTop: '4px', - paddingBottom: '4px', - }} - renderer={renderer} - contentEditable={focusable} - onBeforeInput={(e: any) => { - e.preventDefault() - return false - }} - onKeyDown={(e: any) => { - if (e.code === 'Backspace') { - e.preventDefault() - return false - } - }} - > - {codeValue} - - {!hideCopy && (value || children) && className ? ( -
- + > +
+        
+          {lineNumbers ? (
+            <>
+              {tokens.map((line, idx) => (
+                
+                  
+ {idx + 1} +
+
+ +
+
+ ))} + + ) : ( +
+ {tokens.map((line, idx) => ( + + ))}
- ) : null} -
- ) : ( - {value || children} - )} - + )} + + + +
) } + +function CodeLine({ + tokens: rawTokens, + twoslash, +}: { + tokens: Array + twoslash?: Map> +}) { + let offset = 0 + const tokens = rawTokens.map((token) => { + const newToken = { ...token, offset } + offset += token.content.length + return newToken + }) + + return ( + + {tokens.map((token) => + twoslash?.has(token.offset) ? ( + + ) : ( + + {token.content} + + ) + )} + + ) +} + +function extractCode(children: React.ReactNode): string { + if (typeof children === 'string') return children + const child = Array.isArray(children) ? children[0] : children + if (!!child && typeof child === 'object' && 'props' in child) { + const props = child.props + if (!!props && typeof props === 'object' && 'children' in props) { + const code = props.children + if (typeof code === 'string') return code + } + } + return '' +} + +function extractLang(children: React.ReactNode): BundledLanguage | null { + if (typeof children === 'string') return null + const child = Array.isArray(children) ? children[0] : children + if (!!child && typeof child === 'object' && 'props' in child) { + const props = child.props + if (!!props && typeof props === 'object' && 'className' in props) { + const className = props.className + if (typeof className === 'string') { + const lang = className.split(' ').find((className) => className.startsWith('language-')) + return lang ? tryToBundledLanguage(lang.replace('language-', '')) : null + } + } + } + return null +} + +function annotationsByLine(nodes: Array): Map>> { + const result = new Map() + nodes.forEach((node) => { + const line = node.line + const char = node.character + if (!result.has(line)) { + result.set(line, new Map()) + } + if (!result.get(line).has(char)) { + result.get(line).set(char, []) + } + result.get(line).get(char).push(node) + }) + return result +} + +function tryToBundledLanguage(lang: string): BundledLanguage | null { + if (BUNDLED_LANGUAGES.includes(lang)) { + return lang as BundledLanguage + } + return null +}