{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "code-block", "title": "Code Block", "description": "Code block component with syntax highlighting and line numbers. | シンタックスハイライトと行番号を備えたコードブロックコンポーネント。", "dependencies": [ "shiki", "lucide-react" ], "registryDependencies": [ "select", "button" ], "files": [ { "path": "components/ai-elements/code-block.tsx", "content": "\"use client\";\n\nimport type { ComponentProps, CSSProperties, HTMLAttributes } from \"react\";\nimport type {\n BundledLanguage,\n BundledTheme,\n HighlighterGeneric,\n ThemedToken,\n} from \"shiki\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\";\nimport { cn } from \"@/lib/utils\";\nimport { CheckIcon, CopyIcon } from \"lucide-react\";\nimport {\n createContext,\n memo,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\nimport { createHighlighter } from \"shiki\";\n\n// Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline\n// biome-ignore lint/suspicious/noBitwiseOperators: shiki bitflag check\n// eslint-disable-next-line no-bitwise -- shiki bitflag check\nconst isItalic = (fontStyle: number | undefined) => fontStyle && fontStyle & 1;\n// biome-ignore lint/suspicious/noBitwiseOperators: shiki bitflag check\n// eslint-disable-next-line no-bitwise -- shiki bitflag check\n// oxlint-disable-next-line eslint(no-bitwise)\nconst isBold = (fontStyle: number | undefined) => fontStyle && fontStyle & 2;\nconst isUnderline = (fontStyle: number | undefined) =>\n // biome-ignore lint/suspicious/noBitwiseOperators: shiki bitflag check\n // oxlint-disable-next-line eslint(no-bitwise)\n fontStyle && fontStyle & 4;\n\n// Transform tokens to include pre-computed keys to avoid noArrayIndexKey lint\ninterface KeyedToken {\n token: ThemedToken;\n key: string;\n}\ninterface KeyedLine {\n tokens: KeyedToken[];\n key: string;\n}\n\nconst addKeysToTokens = (lines: ThemedToken[][]): KeyedLine[] =>\n lines.map((line, lineIdx) => ({\n key: `line-${lineIdx}`,\n tokens: line.map((token, tokenIdx) => ({\n key: `line-${lineIdx}-${tokenIdx}`,\n token,\n })),\n }));\n\n// Token rendering component\nconst TokenSpan = ({ token }: { token: ThemedToken }) => (\n \n {token.content}\n \n);\n\n// Line rendering component\nconst LineSpan = ({\n keyedLine,\n showLineNumbers,\n}: {\n keyedLine: KeyedLine;\n showLineNumbers: boolean;\n}) => (\n \n {keyedLine.tokens.length === 0\n ? \"\\n\"\n : keyedLine.tokens.map(({ token, key }) => (\n \n ))}\n \n);\n\n// Types\ntype CodeBlockProps = HTMLAttributes & {\n code: string;\n language: BundledLanguage;\n showLineNumbers?: boolean;\n};\n\ninterface TokenizedCode {\n tokens: ThemedToken[][];\n fg: string;\n bg: string;\n}\n\ninterface CodeBlockContextType {\n code: string;\n}\n\n// Context\nconst CodeBlockContext = createContext({\n code: \"\",\n});\n\n// Highlighter cache (singleton per language)\nconst highlighterCache = new Map<\n string,\n Promise>\n>();\n\n// Token cache\nconst tokensCache = new Map();\n\n// Subscribers for async token updates\nconst subscribers = new Map void>>();\n\nconst getTokensCacheKey = (code: string, language: BundledLanguage) => {\n const start = code.slice(0, 100);\n const end = code.length > 100 ? code.slice(-100) : \"\";\n return `${language}:${code.length}:${start}:${end}`;\n};\n\nconst getHighlighter = (\n language: BundledLanguage\n): Promise> => {\n const cached = highlighterCache.get(language);\n if (cached) {\n return cached;\n }\n\n const highlighterPromise = createHighlighter({\n langs: [language],\n themes: [\"github-light\", \"github-dark\"],\n });\n\n highlighterCache.set(language, highlighterPromise);\n return highlighterPromise;\n};\n\n// Create raw tokens for immediate display while highlighting loads\nconst createRawTokens = (code: string): TokenizedCode => ({\n bg: \"transparent\",\n fg: \"inherit\",\n tokens: code.split(\"\\n\").map((line) =>\n line === \"\"\n ? []\n : [\n {\n color: \"inherit\",\n content: line,\n } as ThemedToken,\n ]\n ),\n});\n\n// Synchronous highlight with callback for async results\nexport const highlightCode = (\n code: string,\n language: BundledLanguage,\n // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)\n callback?: (result: TokenizedCode) => void\n): TokenizedCode | null => {\n const tokensCacheKey = getTokensCacheKey(code, language);\n\n // Return cached result if available\n const cached = tokensCache.get(tokensCacheKey);\n if (cached) {\n return cached;\n }\n\n // Subscribe callback if provided\n if (callback) {\n if (!subscribers.has(tokensCacheKey)) {\n subscribers.set(tokensCacheKey, new Set());\n }\n subscribers.get(tokensCacheKey)?.add(callback);\n }\n\n // Start highlighting in background - fire-and-forget async pattern\n getHighlighter(language)\n // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then)\n .then((highlighter) => {\n const availableLangs = highlighter.getLoadedLanguages();\n const langToUse = availableLangs.includes(language) ? language : \"text\";\n\n const result = highlighter.codeToTokens(code, {\n lang: langToUse,\n themes: {\n dark: \"github-dark\",\n light: \"github-light\",\n },\n });\n\n const tokenized: TokenizedCode = {\n bg: result.bg ?? \"transparent\",\n fg: result.fg ?? \"inherit\",\n tokens: result.tokens,\n };\n\n // Cache the result\n tokensCache.set(tokensCacheKey, tokenized);\n\n // Notify all subscribers\n const subs = subscribers.get(tokensCacheKey);\n if (subs) {\n for (const sub of subs) {\n sub(tokenized);\n }\n subscribers.delete(tokensCacheKey);\n }\n })\n // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then), eslint-plugin-promise(prefer-await-to-callbacks)\n .catch((error) => {\n console.error(\"Failed to highlight code:\", error);\n subscribers.delete(tokensCacheKey);\n });\n\n return null;\n};\n\n// Line number styles using CSS counters\nconst LINE_NUMBER_CLASSES = cn(\n \"block\",\n \"before:content-[counter(line)]\",\n \"before:inline-block\",\n \"before:[counter-increment:line]\",\n \"before:w-8\",\n \"before:mr-4\",\n \"before:text-right\",\n \"before:text-muted-foreground/50\",\n \"before:font-mono\",\n \"before:select-none\"\n);\n\nconst CodeBlockBody = memo(\n ({\n tokenized,\n showLineNumbers,\n className,\n }: {\n tokenized: TokenizedCode;\n showLineNumbers: boolean;\n className?: string;\n }) => {\n const keyedLines = useMemo(\n () => addKeysToTokens(tokenized.tokens),\n [tokenized.tokens]\n );\n\n return (\n \n \n {keyedLines.map((keyedLine) => (\n \n ))}\n \n \n );\n },\n (prevProps, nextProps) =>\n prevProps.tokenized === nextProps.tokenized &&\n prevProps.showLineNumbers === nextProps.showLineNumbers &&\n prevProps.className === nextProps.className\n);\n\nCodeBlockBody.displayName = \"CodeBlockBody\";\n\nexport const CodeBlockContainer = ({\n className,\n language,\n style,\n ...props\n}: HTMLAttributes & { language: string }) => (\n \n);\n\nexport const CodeBlockHeader = ({\n children,\n className,\n ...props\n}: HTMLAttributes) => (\n \n {children}\n \n);\n\nexport const CodeBlockTitle = ({\n children,\n className,\n ...props\n}: HTMLAttributes) => (\n
\n {children}\n
\n);\n\nexport const CodeBlockFilename = ({\n children,\n className,\n ...props\n}: HTMLAttributes) => (\n \n {children}\n \n);\n\nexport const CodeBlockActions = ({\n children,\n className,\n ...props\n}: HTMLAttributes) => (\n \n {children}\n \n);\n\nexport const CodeBlockContent = ({\n code,\n language,\n showLineNumbers = false,\n}: {\n code: string;\n language: BundledLanguage;\n showLineNumbers?: boolean;\n}) => {\n // Memoized raw tokens for immediate display\n const rawTokens = useMemo(() => createRawTokens(code), [code]);\n\n // Try to get cached result synchronously, otherwise use raw tokens\n const [tokenized, setTokenized] = useState(\n () => highlightCode(code, language) ?? rawTokens\n );\n\n useEffect(() => {\n let cancelled = false;\n\n // Reset to raw tokens when code changes (shows current code, not stale tokens)\n setTokenized(highlightCode(code, language) ?? rawTokens);\n\n // Subscribe to async highlighting result\n highlightCode(code, language, (result) => {\n if (!cancelled) {\n setTokenized(result);\n }\n });\n\n return () => {\n cancelled = true;\n };\n }, [code, language, rawTokens]);\n\n return (\n
\n \n
\n );\n};\n\nexport const CodeBlock = ({\n code,\n language,\n showLineNumbers = false,\n className,\n children,\n ...props\n}: CodeBlockProps) => {\n const contextValue = useMemo(() => ({ code }), [code]);\n\n return (\n \n \n {children}\n \n \n \n );\n};\n\nexport type CodeBlockCopyButtonProps = ComponentProps & {\n onCopy?: () => void;\n onError?: (error: Error) => void;\n timeout?: number;\n};\n\nexport const CodeBlockCopyButton = ({\n onCopy,\n onError,\n timeout = 2000,\n children,\n className,\n ...props\n}: CodeBlockCopyButtonProps) => {\n const [isCopied, setIsCopied] = useState(false);\n const timeoutRef = useRef(0);\n const { code } = useContext(CodeBlockContext);\n\n const copyToClipboard = useCallback(async () => {\n if (typeof window === \"undefined\" || !navigator?.clipboard?.writeText) {\n onError?.(new Error(\"Clipboard API not available\"));\n return;\n }\n\n try {\n if (!isCopied) {\n await navigator.clipboard.writeText(code);\n setIsCopied(true);\n onCopy?.();\n timeoutRef.current = window.setTimeout(\n () => setIsCopied(false),\n timeout\n );\n }\n } catch (error) {\n onError?.(error as Error);\n }\n }, [code, onCopy, onError, timeout, isCopied]);\n\n useEffect(\n () => () => {\n window.clearTimeout(timeoutRef.current);\n },\n []\n );\n\n const Icon = isCopied ? CheckIcon : CopyIcon;\n\n return (\n \n {children ?? }\n \n );\n};\n\nexport type CodeBlockLanguageSelectorProps = ComponentProps;\n\nexport const CodeBlockLanguageSelector = (\n props: CodeBlockLanguageSelectorProps\n) =>