{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion","lucide-react"],"description":"A CodeBlock component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport SmoothButton from \"@/components/smoothui/smooth-button\";\nimport { Check, Copy } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\n\nexport type CodeBlockLanguage =\n  | \"bash\"\n  | \"css\"\n  | \"js\"\n  | \"json\"\n  | \"jsx\"\n  | \"sh\"\n  | \"shell\"\n  | \"ts\"\n  | \"tsx\";\n\nexport type CodeBlockProps = {\n  /** Additional CSS classes */\n  className?: string;\n  /** Source code to render */\n  code: string;\n  /** Show a copy-to-clipboard button */\n  copyable?: boolean;\n  /** Optional filename shown in the header */\n  filename?: string;\n  /** 1-indexed line numbers to visually highlight */\n  highlightLines?: number[];\n  /** Language used to select the tokeniser rules */\n  language?: CodeBlockLanguage;\n  /** Maximum height before the block scrolls, e.g. 320 or \"20rem\" */\n  maxHeight?: number | string;\n  /** Show the line number gutter */\n  showLineNumbers?: boolean;\n  /** Reveal the code with a typing animation */\n  typing?: boolean;\n  /** Typing speed in characters per second */\n  typingSpeed?: number;\n  /** Wrap long lines instead of scrolling horizontally */\n  wrap?: boolean;\n};\n\ntype TokenType =\n  | \"attr\"\n  | \"comment\"\n  | \"keyword\"\n  | \"number\"\n  | \"plain\"\n  | \"punctuation\"\n  | \"string\"\n  | \"tag\";\n\ntype Token = { type: TokenType; value: string };\ntype TokenRule = { regex: RegExp; type: TokenType };\n\nconst DEFAULT_LANGUAGE: CodeBlockLanguage = \"tsx\";\nconst DEFAULT_TYPING_SPEED = 40;\nconst COPY_RESET_MS = 2000;\nconst MS_PER_SECOND = 1000;\nconst VIEW_THRESHOLD = 0.2;\n\nconst LINE_COMMENT_RULE: TokenRule = { regex: /\\/\\/[^\\n]*/y, type: \"comment\" };\nconst BLOCK_COMMENT_RULE: TokenRule = {\n  regex: /\\/\\*[\\s\\S]*?\\*\\//y,\n  type: \"comment\",\n};\nconst HASH_COMMENT_RULE: TokenRule = { regex: /#[^\\n]*/y, type: \"comment\" };\nconst STRING_RULE: TokenRule = {\n  regex: /\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*'|`(?:\\\\.|[^`\\\\])*`/y,\n  type: \"string\",\n};\nconst NUMBER_RULE: TokenRule = { regex: /\\b\\d+(?:\\.\\d+)?\\b/y, type: \"number\" };\nconst HEX_COLOR_RULE: TokenRule = {\n  regex: /#[0-9a-fA-F]{3,8}\\b/y,\n  type: \"number\",\n};\nconst JS_KEYWORD_RULE: TokenRule = {\n  regex:\n    /\\b(?:const|let|var|function|return|if|else|for|while|do|switch|case|break|continue|throw|try|catch|finally|new|class|extends|implements|import|export|default|from|as|async|await|typeof|instanceof|in|of|void|null|undefined|true|false|this|super|type|interface|enum|public|private|protected|readonly|static|namespace|declare|never|unknown|any|string|number|boolean|object|symbol)\\b/y,\n  type: \"keyword\",\n};\nconst JSX_TAG_RULE: TokenRule = { regex: /<\\/?[A-Za-z][\\w.]*/y, type: \"tag\" };\nconst JSX_ATTR_RULE: TokenRule = {\n  regex: /\\b[a-zA-Z_][\\w-]*(?=\\s*=)/y,\n  type: \"attr\",\n};\nconst PUNCTUATION_RULE: TokenRule = {\n  regex: /[{}()[\\];:,.<>=+\\-*/%!&|^~?]/y,\n  type: \"punctuation\",\n};\nconst CSS_AT_RULE: TokenRule = { regex: /@[a-zA-Z-]+/y, type: \"keyword\" };\nconst CSS_PROPERTY_RULE: TokenRule = {\n  regex: /\\b[a-zA-Z-]+(?=\\s*:)/y,\n  type: \"attr\",\n};\nconst JSON_KEYWORD_RULE: TokenRule = {\n  regex: /\\b(?:true|false|null)\\b/y,\n  type: \"keyword\",\n};\nconst BASH_VARIABLE_RULE: TokenRule = {\n  regex: /\\$\\{?\\w+\\}?/y,\n  type: \"number\",\n};\nconst BASH_FLAG_RULE: TokenRule = {\n  regex: /(?:^|(?<=\\s))--?[a-zA-Z][\\w-]*/y,\n  type: \"attr\",\n};\nconst BASH_KEYWORD_RULE: TokenRule = {\n  regex:\n    /\\b(?:if|then|else|elif|fi|for|do|done|while|case|esac|function|echo|export|cd|return|exit|local|set|source|pnpm|npm|yarn|git|sudo)\\b/y,\n  type: \"keyword\",\n};\n\nconst RULES_JS: TokenRule[] = [\n  LINE_COMMENT_RULE,\n  BLOCK_COMMENT_RULE,\n  STRING_RULE,\n  JSX_TAG_RULE,\n  JSX_ATTR_RULE,\n  JS_KEYWORD_RULE,\n  NUMBER_RULE,\n  PUNCTUATION_RULE,\n];\nconst RULES_CSS: TokenRule[] = [\n  BLOCK_COMMENT_RULE,\n  STRING_RULE,\n  HEX_COLOR_RULE,\n  CSS_AT_RULE,\n  CSS_PROPERTY_RULE,\n  NUMBER_RULE,\n  PUNCTUATION_RULE,\n];\nconst RULES_BASH: TokenRule[] = [\n  HASH_COMMENT_RULE,\n  STRING_RULE,\n  BASH_VARIABLE_RULE,\n  BASH_FLAG_RULE,\n  BASH_KEYWORD_RULE,\n  NUMBER_RULE,\n  PUNCTUATION_RULE,\n];\nconst RULES_JSON: TokenRule[] = [\n  STRING_RULE,\n  JSON_KEYWORD_RULE,\n  NUMBER_RULE,\n  PUNCTUATION_RULE,\n];\n\nconst LANGUAGE_RULES: Record<CodeBlockLanguage, TokenRule[]> = {\n  bash: RULES_BASH,\n  css: RULES_CSS,\n  js: RULES_JS,\n  json: RULES_JSON,\n  jsx: RULES_JS,\n  sh: RULES_BASH,\n  shell: RULES_BASH,\n  ts: RULES_JS,\n  tsx: RULES_JS,\n};\n\nconst TOKEN_CLASS: Record<TokenType, string> = {\n  attr: \"text-blue-hover\",\n  comment: \"text-muted-foreground italic\",\n  keyword: \"text-blue\",\n  number: \"text-amber-hover\",\n  plain: \"text-foreground\",\n  punctuation: \"text-foreground/70\",\n  string: \"text-green\",\n  tag: \"text-brand\",\n};\n\nconst getRules = (language: CodeBlockLanguage): TokenRule[] =>\n  LANGUAGE_RULES[language] ?? RULES_JS;\n\nconst tokenize = (code: string, rules: TokenRule[]): Token[] => {\n  const tokens: Token[] = [];\n  let pos = 0;\n  let plainBuffer = \"\";\n\n  while (pos < code.length) {\n    let matchedRule: TokenRule | undefined;\n    let matchedValue = \"\";\n\n    for (const rule of rules) {\n      rule.regex.lastIndex = pos;\n      const match = rule.regex.exec(code);\n      const [matchedText] = match ?? [];\n      if (matchedText && matchedText.length > 0) {\n        matchedRule = rule;\n        matchedValue = matchedText;\n        break;\n      }\n    }\n\n    if (matchedRule) {\n      if (plainBuffer) {\n        tokens.push({ type: \"plain\", value: plainBuffer });\n        plainBuffer = \"\";\n      }\n      tokens.push({ type: matchedRule.type, value: matchedValue });\n      pos += matchedValue.length;\n    } else {\n      plainBuffer += code[pos];\n      pos += 1;\n    }\n  }\n\n  if (plainBuffer) {\n    tokens.push({ type: \"plain\", value: plainBuffer });\n  }\n\n  return tokens;\n};\n\nconst sliceTokens = (tokens: Token[], charLimit: number): Token[] => {\n  const result: Token[] = [];\n  let consumed = 0;\n\n  for (const token of tokens) {\n    if (consumed >= charLimit) {\n      break;\n    }\n    const remaining = charLimit - consumed;\n    if (token.value.length <= remaining) {\n      result.push(token);\n      consumed += token.value.length;\n    } else {\n      result.push({ type: token.type, value: token.value.slice(0, remaining) });\n      break;\n    }\n  }\n\n  return result;\n};\n\nconst splitTokensIntoLines = (tokens: Token[]): Token[][] => {\n  const lines: Token[][] = [[]];\n\n  for (const token of tokens) {\n    const parts = token.value.split(\"\\n\");\n    for (const [index, part] of parts.entries()) {\n      if (index > 0) {\n        lines.push([]);\n      }\n      if (part.length > 0) {\n        lines.at(-1)?.push({ type: token.type, value: part });\n      }\n    }\n  }\n\n  return lines;\n};\n\ntype CopyButtonProps = { code: string; reduceMotion: boolean };\n\nconst CopyButton = ({ code, reduceMotion }: CopyButtonProps) => {\n  const [copied, setCopied] = useState(false);\n\n  const handleCopy = useCallback(() => {\n    navigator.clipboard\n      .writeText(code)\n      .then(() => {\n        setCopied(true);\n        setTimeout(() => setCopied(false), COPY_RESET_MS);\n      })\n      .catch(() => {\n        // Clipboard API unavailable; the code remains manually selectable.\n      });\n  }, [code]);\n\n  return (\n    <SmoothButton\n      aria-label={copied ? \"Copied to clipboard\" : \"Copy code\"}\n      className=\"shrink-0 text-muted-foreground hover:text-foreground\"\n      onClick={handleCopy}\n      size=\"icon-sm\"\n      variant=\"ghost\"\n    >\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.span\n          animate={{ opacity: 1, scale: 1 }}\n          className=\"flex\"\n          exit={\n            reduceMotion\n              ? { opacity: 0, transition: { duration: 0 } }\n              : { opacity: 0, scale: 0.6 }\n          }\n          initial={\n            reduceMotion ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0.6 }\n          }\n          key={copied ? \"copied\" : \"idle\"}\n          transition={\n            reduceMotion\n              ? { duration: 0 }\n              : { bounce: 0.1, duration: 0.25, type: \"spring\" }\n          }\n        >\n          {copied ? <Check size={16} /> : <Copy size={16} />}\n        </motion.span>\n      </AnimatePresence>\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {copied ? \"Copied to clipboard\" : \"\"}\n      </span>\n    </SmoothButton>\n  );\n};\n\nconst CodeBlock = ({\n  code,\n  language = DEFAULT_LANGUAGE,\n  filename,\n  showLineNumbers = true,\n  highlightLines,\n  wrap = false,\n  maxHeight,\n  typing = false,\n  typingSpeed = DEFAULT_TYPING_SPEED,\n  copyable = true,\n  className,\n}: CodeBlockProps) => {\n  const shouldReduceMotion = Boolean(useReducedMotion());\n  const containerRef = useRef<HTMLDivElement>(null);\n  const [inView, setInView] = useState(!typing);\n\n  const normalizedCode = useMemo(\n    () => (code.endsWith(\"\\n\") ? code.slice(0, -1) : code),\n    [code]\n  );\n  const highlightSet = useMemo(\n    () => new Set(highlightLines ?? []),\n    [highlightLines]\n  );\n  const fullTokens = useMemo(\n    () => tokenize(normalizedCode, getRules(language)),\n    [normalizedCode, language]\n  );\n  const [revealedCount, setRevealedCount] = useState(\n    typing ? 0 : normalizedCode.length\n  );\n\n  useEffect(() => {\n    if (!typing) {\n      return;\n    }\n    const el = containerRef.current;\n    if (!el || typeof IntersectionObserver === \"undefined\") {\n      setInView(true);\n      return;\n    }\n    const observer = new IntersectionObserver(\n      (entries) => {\n        for (const entry of entries) {\n          if (entry.isIntersecting) {\n            setInView(true);\n            observer.disconnect();\n          }\n        }\n      },\n      { threshold: VIEW_THRESHOLD }\n    );\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [typing]);\n\n  useEffect(() => {\n    if (!typing || shouldReduceMotion) {\n      setRevealedCount(normalizedCode.length);\n      return;\n    }\n    if (!inView) {\n      return;\n    }\n\n    setRevealedCount(0);\n    let frameId = 0;\n    let startTime: number | null = null;\n    const charsPerMs = typingSpeed / MS_PER_SECOND;\n\n    const tick = (time: number) => {\n      if (startTime === null) {\n        startTime = time;\n      }\n      const elapsed = time - startTime;\n      const next = Math.min(\n        normalizedCode.length,\n        Math.floor(elapsed * charsPerMs)\n      );\n      setRevealedCount(next);\n      if (next < normalizedCode.length) {\n        frameId = requestAnimationFrame(tick);\n      }\n    };\n\n    frameId = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(frameId);\n  }, [typing, shouldReduceMotion, inView, normalizedCode, typingSpeed]);\n\n  const visibleTokens = typing\n    ? sliceTokens(fullTokens, revealedCount)\n    : fullTokens;\n  const lines = useMemo(\n    () => splitTokensIntoLines(visibleTokens),\n    [visibleTokens]\n  );\n\n  return (\n    <div\n      className={cn(\n        \"overflow-hidden rounded-xl border border-border bg-muted/30\",\n        className\n      )}\n      ref={containerRef}\n    >\n      {filename || copyable ? (\n        <div className=\"flex items-center justify-between gap-3 border-border border-b bg-muted/50 px-3 py-2\">\n          <div className=\"flex items-center gap-2 overflow-hidden text-muted-foreground text-xs\">\n            {filename ? (\n              <span className=\"truncate font-medium text-foreground\">\n                {filename}\n              </span>\n            ) : null}\n            <span className=\"shrink-0 uppercase tracking-wide\">{language}</span>\n          </div>\n          {copyable ? (\n            <CopyButton code={code} reduceMotion={shouldReduceMotion} />\n          ) : null}\n        </div>\n      ) : null}\n      <div\n        className={cn(!wrap && \"overflow-x-auto\")}\n        style={maxHeight ? { maxHeight, overflowY: \"auto\" } : undefined}\n      >\n        <pre\n          className={cn(\n            \"m-0 py-3 text-[13px] leading-relaxed\",\n            wrap ? \"whitespace-pre-wrap break-words\" : \"whitespace-pre\"\n          )}\n        >\n          <code>\n            {lines.map((lineTokens, lineIndex) => {\n              const lineNumber = lineIndex + 1;\n              const isHighlighted = highlightSet.has(lineNumber);\n              return (\n                <div\n                  className={cn(\n                    \"flex gap-3 border-transparent border-l-2 px-3\",\n                    // Legibility first: the code sitting on a highlighted line\n                    // must keep its full contrast, so the surface barely moves\n                    // and the accent lives entirely in the gutter rail plus a\n                    // line number promoted to full strength.\n                    isHighlighted &&\n                      \"border-brand bg-foreground/[0.045] dark:bg-foreground/[0.07]\"\n                  )}\n                  // biome-ignore lint/suspicious/noArrayIndexKey: lines map 1:1 to their position\n                  key={lineIndex}\n                >\n                  {showLineNumbers ? (\n                    <span\n                      className={cn(\n                        \"w-6 shrink-0 select-none text-right tabular-nums\",\n                        isHighlighted\n                          ? \"text-foreground/80\"\n                          : \"text-muted-foreground/60\"\n                      )}\n                    >\n                      {lineNumber}\n                    </span>\n                  ) : null}\n                  <span className=\"flex-1\">\n                    {lineTokens.length === 0\n                      ? \" \"\n                      : lineTokens.map((token, tokenIndex) => (\n                          <span\n                            className={TOKEN_CLASS[token.type]}\n                            // biome-ignore lint/suspicious/noArrayIndexKey: tokens map 1:1 to their position within a line\n                            key={tokenIndex}\n                          >\n                            {token.value}\n                          </span>\n                        ))}\n                  </span>\n                </div>\n              );\n            })}\n          </code>\n        </pre>\n      </div>\n    </div>\n  );\n};\n\nexport default CodeBlock;\n","path":"index.tsx","target":"components/smoothui/code-block/index.tsx","type":"registry:ui"}],"name":"code-block","registryDependencies":["https://smoothui.dev/r/smooth-button.json","https://smoothui.dev/r/tokens.json"],"title":"Code Block","type":"registry:ui"}