/** * @title Code * @description 본문 속 짧은 코드는 Code 로, 여러 줄 코드는 줄 번호·강조 줄·복사 버튼이 있는 CodeBlock 으로 보여줘요. */import * as React from 'react';import { CheckIcon, CopyIcon } from 'lucide-react';import { cn } from '@/lib/utils';import { IconButton } from '@/components/ui/button';// Atlassian @atlaskit/code — code.js, code-block.js, bidi-warning 스펙.// 원본은 PrismJS 로 문법 강조를 하지만 Steel 은 의존성 없이 단색(color.text)으로 보여준다./** 지원 언어 (원본 SUPPORTED_LANGUAGES 의 이름과 별칭) */const SUPPORTED_LANGUAGES = [ { name: 'PHP', alias: ['php', 'php3', 'php4', 'php5'] }, { name: 'Java', alias: ['java'] }, { name: 'CSharp', alias: ['csharp', 'c#'] }, { name: 'Python', alias: ['python', 'py'] }, { name: 'JavaScript', alias: ['javascript', 'js'] }, { name: 'Html', alias: ['html'] }, { name: 'C++', alias: ['c++', 'cpp', 'clike'] }, { name: 'Ruby', alias: ['ruby', 'rb', 'duby'] }, { name: 'Objective-C', alias: ['objective-c', 'objectivec', 'obj-c', 'objc'] }, { name: 'C', alias: ['c'] }, { name: 'Swift', alias: ['swift'] }, { name: 'TeX', alias: ['tex', 'latex'] }, { name: 'Shell', alias: ['shell', 'bash', 'sh', 'ksh', 'zsh'] }, { name: 'Scala', alias: ['scala'] }, { name: 'Go', alias: ['go'] }, { name: 'ActionScript', alias: ['actionscript', 'actionscript3', 'as'] }, { name: 'ColdFusion', alias: ['coldfusion'] }, { name: 'JavaFX', alias: ['javafx', 'jfx'] }, { name: 'VbNet', alias: ['vbnet', 'vb.net', 'vfp', 'clipper', 'xbase'] }, { name: 'JSON', alias: ['json'] }, { name: 'MATLAB', alias: ['matlab'] }, { name: 'Groovy', alias: ['groovy'] }, { name: 'SQL', alias: ['sql', 'postgresql', 'postgres', 'plpgsql', 'psql', 'postgresql-console', 'postgres-console', 'tsql', 't-sql', 'mysql', 'sqlite'] }, { name: 'R', alias: ['r'] }, { name: 'Perl', alias: ['perl', 'pl'] }, { name: 'Lua', alias: ['lua'] }, { name: 'Pascal', alias: ['pas', 'pascal', 'objectpascal', 'delphi'] }, { name: 'XML', alias: ['xml'] }, { name: 'TypeScript', alias: ['typescript', 'ts'] }, { name: 'CoffeeScript', alias: ['coffeescript', 'coffee-script', 'coffee'] }, { name: 'Haskell', alias: ['haskell', 'hs'] }, { name: 'Puppet', alias: ['puppet'] }, { name: 'Arduino', alias: ['arduino'] }, { name: 'Fortran', alias: ['fortran'] }, { name: 'Erlang', alias: ['erlang', 'erl'] }, { name: 'PowerShell', alias: ['powershell', 'posh', 'ps1', 'psm1'] }, { name: 'Haxe', alias: ['haxe', 'hx', 'hxsl'] }, { name: 'Elixir', alias: ['elixir', 'ex', 'exs'] }, { name: 'Verilog', alias: ['verilog', 'v'] }, { name: 'Rust', alias: ['rust'] }, { name: 'VHDL', alias: ['vhdl'] }, { name: 'Sass', alias: ['sass'] }, { name: 'OCaml', alias: ['ocaml'] }, { name: 'Dart', alias: ['dart'] }, { name: 'CSS', alias: ['css'] }, { name: 'reStructuredText', alias: ['restructuredtext', 'rst', 'rest'] }, { name: 'Kotlin', alias: ['kotlin'] }, { name: 'D', alias: ['d'] }, { name: 'Octave', alias: ['octave'] }, { name: 'QML', alias: ['qbs', 'qml'] }, { name: 'Prolog', alias: ['prolog'] }, { name: 'FoxPro', alias: ['foxpro', 'purebasic'] }, { name: 'Scheme', alias: ['scheme', 'scm'] }, { name: 'CUDA', alias: ['cuda', 'cu'] }, { name: 'Julia', alias: ['julia', 'jl'] }, { name: 'Racket', alias: ['racket', 'rkt'] }, { name: 'Ada', alias: ['ada', 'ada95', 'ada2005'] }, { name: 'Tcl', alias: ['tcl'] }, { name: 'Mathematica', alias: ['mathematica', 'mma', 'nb'] }, { name: 'Autoit', alias: ['autoit'] }, { name: 'StandardML', alias: ['standardmL', 'sml', 'standardml'] }, { name: 'Objective-J', alias: ['objective-j', 'objectivej', 'obj-j', 'objj'] }, { name: 'Smalltalk', alias: ['smalltalk', 'squeak', 'st'] }, { name: 'Vala', alias: ['vala', 'vapi'] }, { name: 'LiveScript', alias: ['livescript', 'live-script'] }, { name: 'XQuery', alias: ['xquery', 'xqy', 'xq', 'xql', 'xqm'] }, { name: 'PlainText', alias: ['text', 'plaintext'] }, { name: 'Yaml', alias: ['yaml', 'yml'] }, { name: 'GraphQL', alias: ['graphql'] }, { name: 'AppleScript', alias: ['applescript'] }, { name: 'Clojure', alias: ['clojure'] }, { name: 'Diff', alias: ['diff'] }, { name: 'VisualBasic', alias: ['visualbasic'] }, { name: 'JSX', alias: ['jsx'] }, { name: 'TSX', alias: ['tsx'] }, { name: 'SplunkSPL', alias: ['splunk-spl'] }, { name: 'Dockerfile', alias: ['docker', 'dockerfile'] }, { name: 'HCL', alias: ['hcl', 'terraform'] }, { name: 'NGINX', alias: ['nginx'] }, { name: 'Protocol Buffers', alias: ['protobuf', 'proto'] }, { name: 'TOML', alias: ['toml'] }, { name: 'Handlebars', alias: ['handlebars', 'mustache'] }, { name: 'Gherkin', alias: ['gherkin', 'cucumber'] }, { name: 'ABAP', alias: ['abap'] }, { name: 'Markdown', alias: ['markdown'] },] as const;export type SupportedLanguages = (typeof SUPPORTED_LANGUAGES)[number]['alias'][number];/** 별칭 → 표시 이름. 모르는 언어는 undefined (= 일반 텍스트) */function getLanguageName(language: string | undefined) { if (!language) return undefined; const match = SUPPORTED_LANGUAGES.find( (l) => l.name === language || (l.alias as readonly string[]).includes(language.toLowerCase()), ); return match && match.name !== 'PlainText' ? match.name : undefined;}// ---------------------------------------------------------------------------// Bidi 경고 — 양방향 제어 문자(Trojan Source)를 <U+202e> 처럼 드러낸다// ---------------------------------------------------------------------------const BIDI_REGEX = /[--]/g;const DEFAULT_BIDI_LABEL = '양방향 문자는 글자가 보이는 순서를 바꿔요. 악성 코드를 숨기는 데 쓰일 수 있어요.';function BidiWarning({ character, label, isTooltipEnabled }: { character: string; label: string; isTooltipEnabled: boolean }) { const code = `U+${character.codePointAt(0)?.toString(16)}`; return ( <> <span data-slot="code-bidi-warning" data-bidi-character-code={code} aria-hidden title={isTooltipEnabled ? label : undefined} className={cn( 'relative', "before:inline-flex before:items-center before:justify-center before:px-ds-050 before:py-ds-0 before:ds-font-body before:font-ds-code before:not-italic before:leading-[18px] before:content-['<'_attr(data-bidi-character-code)_'>']", 'before:pointer-events-auto before:bg-ds-background-warning before:text-ds-text-warning hover:before:bg-ds-background-warning-hovered', )} > {character} </span> <span className="sr-only"> <mark>{code}</mark> </span> </> );}function decorateBidi(text: string, label: string, isTooltipEnabled: boolean): React.ReactNode { const matches = [...text.matchAll(BIDI_REGEX)]; if (matches.length === 0) return text; const out: React.ReactNode[] = []; let cursor = 0; for (const match of matches) { const index = match.index ?? 0; if (cursor !== index) out.push(text.slice(cursor, index)); out.push(<BidiWarning key={index} character={match[0]} label={label} isTooltipEnabled={isTooltipEnabled} />); cursor = index + match[0].length; } if (cursor !== text.length) out.push(text.slice(cursor)); return out;}function decorateChildren(children: React.ReactNode, label: string, isTooltipEnabled: boolean): React.ReactNode { return React.Children.map(children, (child) => { if (typeof child === 'string') return decorateBidi(child, label, isTooltipEnabled); if (React.isValidElement<{ children?: React.ReactNode }>(child) && child.props.children) { return React.cloneElement(child, { children: decorateChildren(child.props.children, label, isTooltipEnabled), }); } return child; });}// ---------------------------------------------------------------------------// Code (인라인)// ---------------------------------------------------------------------------export interface CodeProps extends React.ComponentProps<'code'> { /** 양방향 제어 문자를 경고로 표시해요. */ hasBidiWarnings?: boolean; /** 경고에 마우스를 올렸을 때 보여줄 설명 */ codeBidiWarningLabel?: string; /** 경고 설명 툴팁(title)을 켜요. */ isBidiWarningTooltipEnabled?: boolean; testId?: string;}/** 본문 사이에 짧은 코드 조각을 강조해요. */function Code({ hasBidiWarnings = true, codeBidiWarningLabel = DEFAULT_BIDI_LABEL, isBidiWarningTooltipEnabled = true, testId, className, children, ...props}: CodeProps) { return ( <code data-slot="code" data-testid={testId} className={cn( 'inline overflow-auto rounded-ds-small border-none bg-ds-background-neutral px-[0.5ch] py-[2px] ds-font-code leading-[inherit] text-ds-text', 'whitespace-pre-wrap [overflow-wrap:break-word] [box-decoration-break:clone] [-webkit-box-decoration-break:clone]', className, )} {...props} > {hasBidiWarnings ? decorateChildren(children, codeBidiWarningLabel, isBidiWarningTooltipEnabled) : children} </code> );}// ---------------------------------------------------------------------------// CodeBlock// ---------------------------------------------------------------------------/** "1-3,5" → [1,2,3,5] */function parseHighlight(highlight: string) { if (!highlight) return new Set<number>(); const lines = highlight.split(',').flatMap((part) => { if (part.indexOf('-') > 0) { const [from, to] = part .split('-') .map(Number) .sort((a, b) => a - b); return Array.from({ length: to - from + 1 }, (_, i) => from + i); } return [Number(part)]; }); return new Set(lines.filter((n) => !Number.isNaN(n)));}export interface CodeBlockProps extends Omit<React.ComponentProps<'div'>, 'children'> { /** 보여줄 코드 */ text: string; /** 언어. 표시 이름에만 쓰이고, 문법 강조는 하지 않아요. */ language?: SupportedLanguages | (string & {}); /** 줄 번호를 보여줘요. */ shouldShowLineNumbers?: boolean; /** 첫 줄 번호 */ firstLineNumber?: number; /** 강조할 줄. 예: "3", "1-5", "1-5,7,10-12" */ highlight?: string; /** 강조 줄 시작을 알리는 스크린 리더 문구 */ highlightedStartText?: string; /** 강조 줄 끝을 알리는 스크린 리더 문구 */ highlightedEndText?: string; /** 긴 줄을 가로 스크롤 대신 줄바꿈해요. */ shouldWrapLongLines?: boolean; hasBidiWarnings?: boolean; codeBidiWarningLabel?: string; isBidiWarningTooltipEnabled?: boolean; /** 가로 스크롤이 생겼을 때 스크롤 영역의 이름 */ label?: string; /** (Steel) 오른쪽 위 복사 버튼을 보여줘요. */ hasCopyButton?: boolean; /** (Steel) 오른쪽 위에 언어 이름을 보여줘요. */ shouldShowLanguage?: boolean; testId?: string;}/** 여러 줄 코드를 서식 그대로 보여줘요. */function CodeBlock({ text, language = 'text', shouldShowLineNumbers = true, firstLineNumber = 1, highlight = '', highlightedStartText = '강조 시작', highlightedEndText = '강조 끝', shouldWrapLongLines = false, hasBidiWarnings = true, codeBidiWarningLabel = DEFAULT_BIDI_LABEL, isBidiWarningTooltipEnabled = true, label = '스크롤 가능한 코드', hasCopyButton = true, shouldShowLanguage = true, testId, className, style, ...props}: CodeBlockProps) { const scrollRef = React.useRef<HTMLPreElement>(null); const [isScrollable, setIsScrollable] = React.useState(false); const [copied, setCopied] = React.useState(false); const lines = React.useMemo(() => (text || '').split('\n'), [text]); const highlighted = React.useMemo(() => parseHighlight(highlight), [highlight]); const start = firstLineNumber > 0 ? firstLineNumber : 1; const lastNumber = lines.length + start - 1; const lineNumberWidth = `calc(${String(lastNumber).length}ch + 16px)`; const languageName = getLanguageName(language); // 가로 스크롤이 생기면 키보드로 스크롤할 수 있게 포커스 가능한 region 으로 만든다 React.useEffect(() => { const el = scrollRef.current; if (!el) return; const check = () => setIsScrollable(el.scrollWidth > el.clientWidth); check(); window.addEventListener('resize', check); return () => window.removeEventListener('resize', check); }, [text, shouldWrapLongLines]); React.useEffect(() => { if (!copied) return; const t = window.setTimeout(() => setCopied(false), 2000); return () => window.clearTimeout(t); }, [copied]); const onCopy = async () => { try { await navigator.clipboard.writeText(text); setCopied(true); } catch { // 클립보드 권한이 없으면 아무것도 하지 않는다 } }; const hasToolbar = hasCopyButton || (shouldShowLanguage && languageName); return ( <div data-slot="code-block" data-code-lang={language} data-testid={testId} className={cn('group/code-block relative rounded-ds-large bg-ds-background-neutral text-ds-text', className)} style={{ '--ads-code-line-number-width': lineNumberWidth, ...style } as React.CSSProperties} {...props} > <pre ref={scrollRef} tabIndex={isScrollable ? 0 : undefined} role={isScrollable ? 'region' : undefined} aria-label={isScrollable ? label : undefined} className={cn( 'm-0 flex overflow-x-auto rounded-ds-large ds-font-code leading-5 [direction:ltr] [tab-size:4]', shouldWrapLongLines ? 'whitespace-pre-wrap' : 'whitespace-pre', 'outline-2 outline-offset-2 outline-transparent focus-visible:outline-ds-border-focused', )} > <code className={cn( 'block shrink grow basis-auto py-ds-100', shouldShowLineNumbers ? 'bg-[linear-gradient(to_right,var(--ds-background-neutral)_var(--ads-code-line-number-width),transparent_var(--ads-code-line-number-width))]' : 'px-ds-100', shouldWrapLongLines && 'break-words', hasToolbar && !shouldShowLineNumbers && 'pe-ds-500', )} > {lines.map((line, i) => { const number = start + i; const isHighlighted = highlighted.has(number); const prevHighlighted = highlighted.has(number - 1); return ( <span key={i} data-ds--code--row="" data-ds--code--row--highlight={isHighlighted ? '' : undefined} data-testid={testId ? `${testId}-line-${number}` : undefined} className={cn( shouldShowLineNumbers ? 'flex' : 'block', shouldShowLineNumbers ? 'pe-ds-100' : '', isHighlighted && 'bg-ds-background-neutral', // 강조 구간 경계에 투명 점선 — 고대비 모드에서 경계가 보이게 isHighlighted !== prevHighlighted && i > 0 && 'border-t border-dashed border-transparent', isHighlighted && i === lines.length - 1 && 'border-b border-dashed border-transparent', )} > {isHighlighted && <span className="sr-only"> [{highlightedStartText}] </span>} {shouldShowLineNumbers && ( <span aria-hidden className={cn( 'relative box-border inline-block min-w-(--ads-code-line-number-width) shrink-0 select-none pe-ds-100 text-right not-italic text-ds-text-subtlest', 'me-ds-100', isHighlighted ? "border-s-4 border-solid border-ds-border-focused ps-ds-050 before:absolute before:-top-px before:-left-1 before:w-1 before:border-t before:border-ds-border-focused before:content-['']" : 'ps-ds-100', )} > {number} </span> )} <span className={cn(shouldShowLineNumbers && 'min-w-0 grow', shouldWrapLongLines && '[overflow-wrap:anywhere]')}> {line === '' ? ' ' : hasBidiWarnings ? decorateBidi(line, codeBidiWarningLabel, isBidiWarningTooltipEnabled) : line} </span> {isHighlighted && <span className="sr-only"> [{highlightedEndText}] </span>} </span> ); })} </code> </pre> {hasToolbar && ( <div data-slot="code-block-toolbar" className="absolute top-ds-050 right-ds-050 flex items-center gap-ds-050 opacity-0 transition-opacity duration-150 group-hover/code-block:opacity-100 group-focus-within/code-block:opacity-100 [@media(hover:none)]:opacity-100" > {shouldShowLanguage && languageName && ( <span className="px-ds-050 ds-font-body-small text-ds-text-subtlest select-none">{languageName}</span> )} {hasCopyButton && ( <IconButton spacing="compact" appearance="subtle" label={copied ? '복사했어요' : '코드 복사'} icon={copied ? <CheckIcon /> : <CopyIcon />} onClick={onCopy} /> )} <span className="sr-only" aria-live="polite"> {copied ? '클립보드에 복사했어요' : ''} </span> </div> )} </div> );}export { Code, CodeBlock, SUPPORTED_LANGUAGES };