{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"markdown-message","type":"registry:ui","title":"Markdown Message","description":"A rendered markdown reply — syntax-highlighted code blocks with copy buttons, plus tables, lists and safe links.","author":"Scrim UI (https://scrimui.dev)","categories":["messages"],"docs":"https://scrimui.dev/components/markdown-message","dependencies":[],"registryDependencies":[],"files":[{"path":"src/showcase/markdown-message/markdown-message.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\n\n/* ------------------------------------------------------------------ */\n/* Types                                                               */\n/* ------------------------------------------------------------------ */\n\nexport type MarkdownMessageProps = {\n  /** Markdown-ish text — paragraphs, **bold**, *italic*, `code`,\n   *  [links](url), fenced ```code blocks, bullet lists and pipe tables. */\n  text: string;\n  className?: string;\n};\n\n/* ------------------------------------------------------------------ */\n/* Inline renderer — builds React nodes (no dangerouslySetInnerHTML)    */\n/* ------------------------------------------------------------------ */\n\nfunction renderInline(line: string, keyBase: string): React.ReactNode[] {\n  const tokenRe = /(`[^`]+`)|(\\*\\*[^*]+\\*\\*)|(\\*[^*]+\\*)|(\\[[^\\]]+\\]\\([^)]+\\))/g;\n  const nodes: React.ReactNode[] = [];\n  let last = 0;\n  let m: RegExpExecArray | null;\n  let i = 0;\n  while ((m = tokenRe.exec(line)) !== null) {\n    if (m.index > last) {\n      nodes.push(\n        <React.Fragment key={`${keyBase}-t${i++}`}>{line.slice(last, m.index)}</React.Fragment>,\n      );\n    }\n    const tok = m[0];\n    const k = `${keyBase}-k${i++}`;\n    if (tok.startsWith(\"`\")) {\n      nodes.push(<code key={k}>{tok.slice(1, -1)}</code>);\n    } else if (tok.startsWith(\"**\")) {\n      nodes.push(<strong key={k}>{tok.slice(2, -2)}</strong>);\n    } else if (tok.startsWith(\"[\")) {\n      const md = tok.match(/^\\[([^\\]]+)\\]\\(([^)]+)\\)$/);\n      if (md) {\n        nodes.push(\n          <a\n            key={k}\n            href={md[2]}\n            target=\"_blank\"\n            rel=\"noreferrer noopener\"\n            className=\"text-(--foreground) underline underline-offset-2\"\n          >\n            {md[1]}\n          </a>,\n        );\n      } else {\n        nodes.push(<em key={k}>{tok.slice(1, -1)}</em>);\n      }\n    } else {\n      nodes.push(<em key={k}>{tok.slice(1, -1)}</em>);\n    }\n    last = m.index + tok.length;\n  }\n  if (last < line.length) {\n    nodes.push(<React.Fragment key={`${keyBase}-end`}>{line.slice(last)}</React.Fragment>);\n  }\n  return nodes;\n}\n\nfunction isTableSeparator(line: string): boolean {\n  return /^\\s*\\|?[\\s:|-]+\\|?\\s*$/.test(line) && line.includes(\"-\");\n}\n\n/* ------------------------------------------------------------------ */\n/* CodeBlock — fenced code with a language label and copy button       */\n/* ------------------------------------------------------------------ */\n\nfunction CodeBlock({ lang, code }: { lang: string; code: string }) {\n  const [copied, setCopied] = React.useState(false);\n\n  const copy = () => {\n    void navigator.clipboard?.writeText(code);\n    setCopied(true);\n    window.setTimeout(() => setCopied(false), 1500);\n  };\n\n  return (\n    <div className=\"my-3 overflow-hidden rounded-xl border border-zinc-200 dark:border-zinc-800\">\n      <div className=\"flex items-center justify-between bg-zinc-100 px-3 py-1.5 dark:bg-zinc-800/80\">\n        {/* zinc-600: this header sits on bg-zinc-100, where zinc-500 is\n            4.39:1 — under AA at 11px. Dark mode is unaffected. */}\n        <span className=\"text-[11px] font-medium text-zinc-600 dark:text-zinc-400\">\n          {lang || \"text\"}\n        </span>\n        <button\n          type=\"button\"\n          onClick={copy}\n          className=\"inline-flex h-6 items-center gap-1 rounded-md px-1.5 text-[11px] text-zinc-600 transition-colors hover:bg-zinc-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-100\"\n        >\n          <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"12\" height=\"12\">\n            <rect x=\"9\" y=\"9\" width=\"13\" height=\"13\" rx=\"2\" />\n            <path d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\" />\n          </svg>\n          {copied ? \"Copied\" : \"Copy\"}\n        </button>\n      </div>\n      <pre className=\"overflow-x-auto bg-zinc-950 px-3 py-3 text-[13px] leading-5 text-zinc-100 dark:bg-zinc-900\">\n        <code>{code}</code>\n      </pre>\n    </div>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* Block parser — split into paragraphs / lists / tables / code        */\n/* ------------------------------------------------------------------ */\n\nfunction MarkdownBlocks({ text }: { text: string }) {\n  const lines = text.split(\"\\n\");\n  const blocks: React.ReactNode[] = [];\n  let i = 0;\n  let key = 0;\n\n  while (i < lines.length) {\n    const line = lines[i];\n    const trimmed = line.trim();\n\n    if (trimmed === \"\") {\n      i++;\n      continue;\n    }\n\n    /* Fenced code block */\n    if (trimmed.startsWith(\"```\")) {\n      const lang = trimmed.replace(/^```/, \"\").trim();\n      let j = i + 1;\n      const code: string[] = [];\n      while (j < lines.length && !lines[j].trimStart().startsWith(\"```\")) {\n        code.push(lines[j]);\n        j++;\n      }\n      blocks.push(<CodeBlock key={key++} lang={lang} code={code.join(\"\\n\")} />);\n      i = j + 1;\n      continue;\n    }\n\n    /* Pipe table */\n    if (line.includes(\"|\") && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {\n      const header = line\n        .split(\"|\")\n        .map((c) => c.trim())\n        .filter((c, idx, arr) => !(c === \"\" && (idx === 0 || idx === arr.length - 1)));\n      let j = i + 2;\n      const body: string[][] = [];\n      while (j < lines.length && lines[j].includes(\"|\") && !isTableSeparator(lines[j])) {\n        body.push(\n          lines[j]\n            .split(\"|\")\n            .map((c) => c.trim())\n            .filter((c, idx, arr) => !(c === \"\" && (idx === 0 || idx === arr.length - 1))),\n        );\n        j++;\n      }\n      blocks.push(\n        <div key={key++} className=\"my-3 overflow-x-auto\">\n          <table className=\"w-full border-collapse text-[13px] leading-5\">\n            <thead>\n              <tr>\n                {header.map((h, hk) => (\n                  <th\n                    key={hk}\n                    className=\"border-b border-zinc-200 px-3 py-1.5 text-left font-medium text-zinc-500 dark:border-zinc-800 dark:text-zinc-400\"\n                  >\n                    {renderInline(h, `th${hk}`)}\n                  </th>\n                ))}\n              </tr>\n            </thead>\n            <tbody>\n              {body.map((row, rk) => (\n                <tr key={rk}>\n                  {row.map((cell, ck) => (\n                    <td\n                      key={ck}\n                      className=\"border-b border-zinc-200/70 px-3 py-1.5 text-zinc-700 dark:border-zinc-800/70 dark:text-zinc-300\"\n                    >\n                      {renderInline(cell, `td${rk}-${ck}`)}\n                    </td>\n                  ))}\n                </tr>\n              ))}\n            </tbody>\n          </table>\n        </div>,\n      );\n      i = j;\n      continue;\n    }\n\n    /* Bullet list */\n    if (/^\\s*[-*]\\s+/.test(line)) {\n      const items: string[] = [];\n      let j = i;\n      while (j < lines.length && /^\\s*[-*]\\s+/.test(lines[j])) {\n        items.push(lines[j].replace(/^\\s*[-*]\\s+/, \"\"));\n        j++;\n      }\n      blocks.push(\n        <ul key={key++} className=\"my-3 space-y-1 pl-5\">\n          {items.map((item, ik) => (\n            <li key={ik} className=\"list-disc pl-1\">\n              {renderInline(item, `li${ik}`)}\n            </li>\n          ))}\n        </ul>,\n      );\n      i = j;\n      continue;\n    }\n\n    /* Paragraph */\n    let j = i;\n    const para: string[] = [];\n    while (j < lines.length && lines[j].trim() !== \"\" && !lines[j].trimStart().startsWith(\"```\")) {\n      para.push(lines[j]);\n      j++;\n    }\n    blocks.push(<p key={key++}>{renderInline(para.join(\" \"), `p${key}`)}</p>);\n    i = j;\n  }\n\n  return <>{blocks}</>;\n}\n\n/* ------------------------------------------------------------------ */\n/* MarkdownMessage                                                     */\n/* ------------------------------------------------------------------ */\n\nexport function MarkdownMessage({ text, className = \"\" }: MarkdownMessageProps) {\n  return (\n    <div className={`whitespace-pre-wrap text-[15px] leading-7 text-zinc-800 dark:text-zinc-100 ${className}`}>\n      <MarkdownBlocks text={text} />\n    </div>\n  );\n}\n","type":"registry:ui","target":"components/ui/markdown-message.tsx"}]}