// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file CodeBlock.tsx * @input Uses React, StyleX, theme tokens, CSS Custom Highlight API, SyntaxTheme provider * @output Exports CodeBlock component and CodeBlockProps * @position Core implementation; read-only syntax-highlighted code display */ import { useInsertionEffect, useEffect, useId, useRef, useState, useCallback, useMemo, type CSSProperties } from 'react'; import * as React from 'react'; import type { BaseProps } from "../BaseProps.js"; import * as stylex from "../style-runtime.js"; import "../theme/tokens.stylex.js"; import { colorVars, spacingVars, radiusVars, typographyVars, fontWeightVars, typeScaleVars, borderVars, durationVars, easeVars } from "../theme/tokens.stylex.js"; import { mergeProps } from "../utils/index.js"; import { useClipboard } from "../hooks/useClipboard.js"; import { Icon } from "../Icon/index.js"; import { IconButton } from "../IconButton/index.js"; import { tokenize, tokenizeAsync, flatTokensToLines, SYNC_TOKENIZE_THRESHOLD } from "./tokenizer.js"; import type { SyntaxToken, TokenLine } from "./tokenizer.js"; import { ensureHighlightStyles } from "./highlightStyles.js"; import { applyHighlightRangesChunked } from "./highlightRanges.js"; import { themeProps } from "../utils/themeProps.js"; import { focusOutlineProps } from "../utils/focusOutline.stylex.js"; import { useTranslator } from "../i18n/index.js"; import { SyntaxTheme, type SyntaxThemeDefinition } from "../theme/syntax/index.js"; // --------------------------------------------------------------------------- // Styles // --------------------------------------------------------------------------- const containerStyles = { card: { kaIpWk: "xh6dtrn", kMzoRj: "x1litavf", ksu8eU: "x1y0btm7", kVAM5u: "x14i3s5s", $$css: true as const }, section: { kaIpWk: "x2u8bby", kMzoRj: "xc342km", ksu8eU: "xng3xce", kVAM5u: "x9r1u3d", "--color-syntax-background": "x1mtvhk1", $$css: true as const } }; const _temp = { "--_codeblock-gutter-width": "xtk08jq", "$$css": true }; const dynamicStyles = { width: (value: string) => [{ kzqmXN: value != null ? "x5lhr3w" : value, k7Eaqz: (value === 'fit-content' ? 'min(100%, 400px)' : null) != null ? "xkj4a21" : value === 'fit-content' ? 'min(100%, 400px)' : null, ks0D6T: (value === 'fit-content' ? '100%' : null) != null ? "xf68679" : value === 'fit-content' ? '100%' : null, $$css: true as const }, { "--x-width": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(value), "--x-minWidth": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(value === 'fit-content' ? 'min(100%, 400px)' : null), "--x-maxWidth": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(value === 'fit-content' ? '100%' : null) }], gutterWidth: (digits: number) => [_temp, { "--x---_codeblock-gutter-width": `${digits}ch` != null ? `${digits}ch` : undefined }] }; // Light reveal so the leading chevron eases into view instead of popping in. // Growing the chevron's own footprint (width + inline margin) from zero slides // the title into place instead of snapping it over, and clipping keeps the // glyph from spilling while it's mid-reveal. const chevronReveal = "x1xhkexc-B"; const styles = { root: { kVAEAm: "x1n2onr6", kHBbk8: "xc8icb0", k1xSpc: "x78zum5", kXwgrk: "xdt5ytf", kogj98: "x1ghz6dp", kWkggS: "xzyc27v", kVQacm: "xb3r6kr", $$css: true as const }, header: { k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kUk6DE: "x98rzlu", k7Eaqz: "xeuugli", kmVPX3: "x1717udv", kWkggS: "xjbqb8w", kMwMTN: "x1heor9g", kVVagm: "xln7xf2", k9WMMc: "x1yc453h", $$css: true as const }, collapseChevronIcon: { k1ekBW: "x11xpdln", kIyJzY: "xuedmi6", kAMwcw: "xlr8y92", $$css: true as const }, collapseChevronExpanded: { k3aq6I: "x1iffjtl", $$css: true as const }, headerCollapsible: { kkrTdU: "x1ypdohk x16khyan", kfSwDN: "x87ps6o", $$css: true as const }, code: { k1xSpc: "x1lliihq", kUk6DE: "x98rzlu", k8WAf4: "x8o8v82", kg3NbH: "x1pzlopt", kogj98: "x1ghz6dp", kMv6JI: "x9m5x89", kMwMTN: "xdfisg", kKi2Bq: "xeq4nuv", khDVqt: "x1sdyfia", kTgw9: "x1lldw8n", kHjlTd: "x1h4wwuj", $$css: true as const }, codeWrapped: { khDVqt: "x126k92a", kTgw9: "x1yn0g08", kHjlTd: "x1mzt3pk", $$css: true as const }, codeNumbered: { kVAEAm: "x1n2onr6", k5JduY: "x1s928wv", kwXMNM: "x1j6awrg", kSy8m5: "xd54j2p", kc1e00: "x1km86hw", kkgrvl: "xkj6t0a", kQlJh0: "x1p96cux", k6vLHU: "xvsnedh", kkQ525: "xnnzo76", kloYau: "x2q1x1w", $$css: true as const }, sizeSm: { kGuDYH: "x141an7d", $$css: true as const }, sizeMd: { kGuDYH: "xp03k98", $$css: true as const }, copyButton: { kMwMTN: "x1mjzq1q", $$css: true as const }, copyButtonAbsolute: { kVAEAm: "x10l6tqk", k87sOh: "xctzyg", kt4wiu: "x72tfeb", $$css: true as const } }; // --------------------------------------------------------------------------- // Line rendering // --------------------------------------------------------------------------- const LINE_CHUNK_SIZE = 20; const LINE_CHUNK_THRESHOLD = 100; /** * Memoized chunk component — cheaper than memoizing every individual line. */ const CodeChunk = React.memo(function CodeChunk({ lines, startIndex, highlightSet, renderLineContent, lineNumbers }: { lines: string[]; startIndex: number; highlightSet: Set | null; renderLineContent: (line: string, lineIndex: number) => React.ReactNode; lineNumbers: boolean; }) { return <> {lines.map((line, j) => { const i = startIndex + j; return
{renderLineContent(line, i)}
; })} ; }); function renderLines(lines: string[], highlightSet: Set | null, renderLineContent: (line: string, lineIndex: number) => React.ReactNode, lineNumbers: boolean, chunkSize: number = LINE_CHUNK_SIZE): React.ReactNode { chunkSize = Math.max(1, Math.floor(chunkSize)); if (lines.length < LINE_CHUNK_THRESHOLD) { return ; } const chunks: React.ReactNode[] = []; for (let start = 0; start < lines.length; start += chunkSize) { const end = Math.min(start + chunkSize, lines.length); const chunkLines = lines.slice(start, end); const estimatedHeight = `${chunkLines.length}lh`; chunks.push(
); } return chunks; } // --------------------------------------------------------------------------- // Props // --------------------------------------------------------------------------- export interface CodeBlockProps extends BaseProps { ref?: React.Ref; code: string; language?: string; title?: string; hasLanguageLabel?: boolean; hasLineNumbers?: boolean; highlightLines?: number[]; hasCopyButton?: boolean; onCopy?: () => void; isWrapped?: boolean; maxHeight?: number | string; isCollapsible?: boolean; collapsibleThreshold?: number; size?: 'sm' | 'md'; /** * Width of the code block. Accepts any CSS width value. * - `'fit-content'` (default): shrinks to the width of the longest line (with a min-width floor). * - `'100%'`: stretches to fill the parent container width. * - Any valid CSS width string (e.g. `'600px'`, `'50vw'`). * @default 'fit-content' */ width?: string; /** * Container presentation style. * - `'card'` (default): border-radius and border with the muted syntax * background — standalone card look. * - `'section'`: no border-radius, no border, and a transparent background * so the block blends into the card or panel it's embedded in. Set an * explicit background via `xstyle` if you need one. * @default 'card' */ container?: 'card' | 'section'; tokenizer?: (code: string, language: string) => { type: string; start: number; end: number; }[]; highlightMode?: 'auto' | 'ranges' | 'spans'; /** * Per-instance syntax theme override. Shorthand for wrapping this block in * `` — accepts a preset from * `@astryxdesign/core/theme/syntax` or a theme created with * `defineSyntaxTheme()`. Without it, the block uses the theme-level syntax * colors from the nearest SyntaxTheme ancestor or `defineTheme({ syntax })`. */ syntaxTheme?: SyntaxThemeDefinition; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function hasHighlightAPI(): boolean { return typeof CSS !== 'undefined' && 'highlights' in CSS && typeof Highlight !== 'undefined'; } /** * Safari supports the Highlight API JS objects but has rendering issues * with ::highlight() in code blocks. Detect Safari (WebKit without Chrome) * so we can fall back to spans. */ function isSafari(): boolean { if (typeof navigator === 'undefined') { return false; } const ua = navigator.userAgent; return ua.includes('AppleWebKit') && !ua.includes('Chrome'); } /** * Hook: per-line tokens with sync/async + custom tokenizer compat. */ function useTokenLines(code: string, language: string, customTokenizer?: CodeBlockProps['tokenizer']): TokenLine[] { const [asyncTokenResult, setAsyncTokenResult] = useState<{ code: string; language: string; tokens: TokenLine[]; } | null>(null); const syncTokens = useMemo(() => { if (customTokenizer) { return flatTokensToLines(customTokenizer(code, language), code); } if (code.length >= SYNC_TOKENIZE_THRESHOLD) { return null; } return tokenize(code, language); }, [code, language, customTokenizer]); useEffect(() => { if (code.length < SYNC_TOKENIZE_THRESHOLD || customTokenizer) { return; } const abortController = new AbortController(); async function tokenizeLargeCode() { try { const tokens = await tokenizeAsync(code, language, abortController.signal); if (!abortController.signal.aborted) { setAsyncTokenResult({ code, language, tokens }); } } catch { if (!abortController.signal.aborted) { setAsyncTokenResult({ code, language, tokens: [] }); } } } void tokenizeLargeCode(); return () => { abortController.abort(); }; }, [code, language, customTokenizer]); if (syncTokens != null) { return syncTokens; } if (asyncTokenResult?.code === code && asyncTokenResult.language === language) { return asyncTokenResult.tokens; } return []; } // --------------------------------------------------------------------------- // Span-mode code element // --------------------------------------------------------------------------- function buildSpanLine(lineText: string, tokens: SyntaxToken[]): React.ReactNode { if (tokens.length === 0) { return lineText || '\u200b'; } const parts: React.ReactNode[] = []; let cursor = 0; for (const token of tokens) { if (token.start > cursor) { parts.push(lineText.slice(cursor, token.start)); } const end = Math.min(token.end, lineText.length); parts.push( {lineText.slice(token.start, end)} ); cursor = end; } if (cursor < lineText.length) { parts.push(lineText.slice(cursor)); } return parts.length > 0 ? parts : '\u200b'; } function SpanCodeContent({ lines, tokenLines, highlightSet, isWrapped, sizeStyle, hasLineNumbers, maxDigits }: { lines: string[]; tokenLines: TokenLine[]; highlightSet: Set | null; isWrapped: boolean; sizeStyle: stylex.StyleXStyles; hasLineNumbers: boolean; maxDigits: number; }) { useInsertionEffect(() => { ensureHighlightStyles(); }, []); const renderLineContent = useCallback((line: string, lineIndex: number): React.ReactNode => { const tokens = tokenLines[lineIndex] ?? []; // Wrap tokens in a single element so they occupy one grid cell when line // numbers are on (see `lineNumbered`); an inline span is a no-op when off. return {buildSpanLine(line, tokens)} ; }, [tokenLines]); return {renderLines(lines, highlightSet, renderLineContent, hasLineNumbers)} ; } // --------------------------------------------------------------------------- // Range-mode code element // --------------------------------------------------------------------------- function RangeCodeContent({ lines, tokenLines, highlightSet, isWrapped, sizeStyle, hasLineNumbers, maxDigits }: { lines: string[]; tokenLines: TokenLine[]; highlightSet: Set | null; isWrapped: boolean; sizeStyle: stylex.StyleXStyles; hasLineNumbers: boolean; maxDigits: number; }) { const codeRef = useRef(null); useEffect(() => { if (!hasHighlightAPI()) { return; } ensureHighlightStyles(); const codeEl = codeRef.current; if (!codeEl || tokenLines.length === 0) { return; } return applyHighlightRangesChunked(codeEl, tokenLines); }, [tokenLines]); // Range mode keeps the line's text as a bare text node (its firstChild) so // applyHighlightRangesChunked can map token offsets onto it \u2014 no wrapper. The // number ::before is a pseudo-element, so it never becomes a child node here. const renderLineContent = useCallback((line: string): React.ReactNode => line || '\u200b', []); return {renderLines(lines, highlightSet, renderLineContent, hasLineNumbers)} ; } // --------------------------------------------------------------------------- // Main component // --------------------------------------------------------------------------- /** * Read-only code display with syntax highlighting, line numbers, * and optional copy button. * * @example * ``` * * ``` */ export function CodeBlock({ code, language = 'plaintext', title, hasLanguageLabel = true, hasLineNumbers = false, highlightLines, hasCopyButton = true, onCopy, isWrapped = false, maxHeight, isCollapsible = false, collapsibleThreshold = 10, size = 'md', width: widthProp = 'fit-content', container = 'card', tokenizer: customTokenizer, highlightMode = 'auto', syntaxTheme, xstyle, className, style, ref, ...props }: CodeBlockProps) { const t = useTranslator(); // Owns the clipboard write, the transient copied flag, its reset timer, and // the polite copy announcement (a swapped aria-label alone is not reliably // announced) — shared with Timestamp via the same hook. const { copy, isCopied: copied } = useClipboard({ announce: t('@astryx.codeBlock.copied') }); const useSpans = highlightMode === 'spans' || highlightMode === 'auto' && !hasHighlightAPI() || highlightMode === 'auto' && isSafari(); const lines = useMemo(() => { const l = code.split('\n'); if (l.length > 1 && l[l.length - 1] === '') { l.pop(); } return l; }, [code]); const tokenLines = useTokenLines(code, language, customTokenizer); const highlightSet = useMemo(() => highlightLines ? new Set(highlightLines) : null, [highlightLines]); const handleCopy = useCallback(async () => { const didCopy = await copy(code); if (didCopy) { onCopy?.(); } }, [code, copy, onCopy]); const sizeStyle = size === 'sm' ? styles.sizeSm : styles.sizeMd; // Digits in the largest line number — sizes the gutter column width. const maxLineDigits = String(lines.length).length; const languageLabel = hasLanguageLabel && language !== 'plaintext' ? language : null; const showHeader = title != null || languageLabel != null; const canCollapse = isCollapsible && lines.length >= collapsibleThreshold; const [isCollapsed, setIsCollapsed] = useState(false); // Links the collapsible header to the code region it shows/hides so assistive // tech can move from the button to its controlled content (disclosure // pattern). The region stays mounted when collapsed (CSS grid animation), so // this is always a resolvable reference — aria-controls can be unconditional. const regionId = useId(); const scrollContainerRef = useRef(null); const scrollStyle: CSSProperties | undefined = maxHeight ? { maxHeight: typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight } : undefined; const copyButtonEl = hasCopyButton ? } // A visible "Copy" hover/focus hint via Button's built-in tooltip. It // stays "Copy" after copying — the copy → check icon flip is the // confirmation, not a tooltip change. The aria-label still swaps to the // localized "Copied" for assistive tech, backed by the announcement. tooltip={t('@astryx.codeBlock.copyCode')} label={copied ? t('@astryx.codeBlock.copied') : t('@astryx.codeBlock.copyCode')} onClick={e => { // Stop propagation so copying does not toggle the collapsible header. e.stopPropagation(); void handleCopy(); }} xstyle={[styles.copyButton, !showHeader && styles.copyButtonAbsolute]} {...themeProps('code-block-copy-button', undefined, { legacyNames: ['codeblock-copy-button'] })} /> : null; const headerEl = showHeader ?
setIsCollapsed(prev => !prev) : undefined} onKeyDown={canCollapse ? (e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setIsCollapsed(prev => !prev); } } : undefined} {...focusOutlineProps.focusVisible(styles.header, canCollapse && styles.headerCollapsible)}> {canCollapse && } {title} {title && languageLabel ? ' — ' : ''} {languageLabel}
{copyButtonEl}
: null; const codeBody =
{useSpans ? : }
; const block =
      {headerEl}
      {canCollapse ?  : codeBody}
      {!showHeader && copyButtonEl}
    
; return syntaxTheme ? {block} : block; } CodeBlock.displayName = 'CodeBlock';