{
  "name": "plan-document",
  "dependencies": [],
  "registryDependencies": [
    "file-icon",
    "syntax"
  ],
  "files": [
    {
      "path": "plan-document.tsx",
      "content": "\"use client\";\r\n\r\nimport { css, themeVars as theme } from \"@yugnex/core\";\r\nimport { useMemo, type ReactNode } from \"react\";\r\nimport { FileIcon, type FileStatus } from \"./file-icon\";\r\nimport { languageFromFilename, Syntax, type SyntaxLanguage } from \"./syntax\";\r\n\r\n/* ------------------------------------------------------------------ *\r\n * Block model\r\n * ------------------------------------------------------------------ */\r\n\r\nexport type AlertKind = \"note\" | \"tip\" | \"important\" | \"warning\" | \"caution\";\r\n\r\nexport type Block =\r\n  | { type: \"heading\"; level: 1 | 2 | 3 | 4 | 5 | 6; text: string }\r\n  | { type: \"paragraph\"; text: string }\r\n  | { type: \"code\"; lang?: string; code: string; /** Still being streamed — no closing fence yet. */ open: boolean }\r\n  | { type: \"list\"; ordered: boolean; items: string[] }\r\n  | { type: \"table\"; header: string[]; rows: string[][]; align: Array<\"left\" | \"center\" | \"right\"> }\r\n  | { type: \"alert\"; kind: AlertKind; lines: string[] }\r\n  | { type: \"quote\"; lines: string[] }\r\n  | { type: \"rule\" }\r\n  | { type: \"mermaid\"; code: string; open: boolean };\r\n\r\nconst ALERT_RE = /^\\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\\]\\s*$/i;\r\n\r\n/**\r\n * Parses markdown into blocks.\r\n *\r\n * Written to tolerate a *truncated* document, because that is the normal case\r\n * here: the plan is rendered while it streams, so the last block is routinely\r\n * an unterminated fence, a half-written table, or a heading with no body yet.\r\n * Anything still open is emitted with `open: true` rather than discarded, so\r\n * content appears as it arrives instead of snapping in at the closing fence.\r\n */\r\nexport function parsePlan(source: string): Block[] {\r\n  const lines = source.split(\"\\n\");\r\n  const blocks: Block[] = [];\r\n  let i = 0;\r\n\r\n  const flushParagraph = (buffer: string[]) => {\r\n    if (buffer.length === 0) return;\r\n    blocks.push({ type: \"paragraph\", text: buffer.join(\"\\n\") });\r\n    buffer.length = 0;\r\n  };\r\n\r\n  const paragraph: string[] = [];\r\n\r\n  while (i < lines.length) {\r\n    const line = lines[i] as string;\r\n\r\n    // Fenced code (and mermaid).\r\n    const fence = /^\\s*```+\\s*([A-Za-z0-9_+-]*)\\s*$/.exec(line);\r\n    if (fence) {\r\n      flushParagraph(paragraph);\r\n      const lang = (fence[1] ?? \"\").toLowerCase();\r\n      const body: string[] = [];\r\n      i++;\r\n      let closed = false;\r\n      while (i < lines.length) {\r\n        if (/^\\s*```+\\s*$/.test(lines[i] as string)) {\r\n          closed = true;\r\n          i++;\r\n          break;\r\n        }\r\n        body.push(lines[i] as string);\r\n        i++;\r\n      }\r\n      const code = body.join(\"\\n\");\r\n      if (lang === \"mermaid\") blocks.push({ type: \"mermaid\", code, open: !closed });\r\n      else blocks.push({ type: \"code\", lang: lang || undefined, code, open: !closed });\r\n      continue;\r\n    }\r\n\r\n    // Heading.\r\n    const heading = /^\\s{0,3}(#{1,6})\\s+(.*)$/.exec(line);\r\n    if (heading) {\r\n      flushParagraph(paragraph);\r\n      blocks.push({\r\n        type: \"heading\",\r\n        level: (heading[1] as string).length as 1 | 2 | 3 | 4 | 5 | 6,\r\n        text: (heading[2] as string).trim(),\r\n      });\r\n      i++;\r\n      continue;\r\n    }\r\n\r\n    // Horizontal rule.\r\n    if (/^\\s{0,3}(?:[-*_]\\s*){3,}$/.test(line)) {\r\n      flushParagraph(paragraph);\r\n      blocks.push({ type: \"rule\" });\r\n      i++;\r\n      continue;\r\n    }\r\n\r\n    // Blockquote — GFM alerts are a blockquote whose first line is [!KIND].\r\n    if (/^\\s{0,3}>/.test(line)) {\r\n      flushParagraph(paragraph);\r\n      const quoted: string[] = [];\r\n      while (i < lines.length && /^\\s{0,3}>/.test(lines[i] as string)) {\r\n        quoted.push((lines[i] as string).replace(/^\\s{0,3}>\\s?/, \"\"));\r\n        i++;\r\n      }\r\n      const alert = quoted.length > 0 ? ALERT_RE.exec(quoted[0] as string) : null;\r\n      if (alert) {\r\n        blocks.push({\r\n          type: \"alert\",\r\n          kind: (alert[1] as string).toLowerCase() as AlertKind,\r\n          lines: quoted.slice(1),\r\n        });\r\n      } else {\r\n        blocks.push({ type: \"quote\", lines: quoted });\r\n      }\r\n      continue;\r\n    }\r\n\r\n    // Table — a header row followed by a delimiter row.\r\n    if (line.includes(\"|\") && i + 1 < lines.length && /^\\s*\\|?[\\s:|-]+\\|[\\s:|-]*$/.test(lines[i + 1] as string)) {\r\n      flushParagraph(paragraph);\r\n      const header = splitRow(line);\r\n      const align = splitRow(lines[i + 1] as string).map((cell) => {\r\n        const left = cell.startsWith(\":\");\r\n        const right = cell.endsWith(\":\");\r\n        if (left && right) return \"center\" as const;\r\n        if (right) return \"right\" as const;\r\n        return \"left\" as const;\r\n      });\r\n      i += 2;\r\n      const rows: string[][] = [];\r\n      while (i < lines.length && (lines[i] as string).includes(\"|\") && (lines[i] as string).trim() !== \"\") {\r\n        rows.push(splitRow(lines[i] as string));\r\n        i++;\r\n      }\r\n      blocks.push({ type: \"table\", header, rows, align });\r\n      continue;\r\n    }\r\n\r\n    // List.\r\n    const bullet = /^\\s{0,3}(?:[-*+]|\\d+\\.)\\s+(.*)$/.exec(line);\r\n    if (bullet) {\r\n      flushParagraph(paragraph);\r\n      const ordered = /^\\s{0,3}\\d+\\./.test(line);\r\n      const items: string[] = [];\r\n      while (i < lines.length) {\r\n        const match = /^\\s{0,3}(?:[-*+]|\\d+\\.)\\s+(.*)$/.exec(lines[i] as string);\r\n        if (!match) break;\r\n        items.push((match[1] as string).trim());\r\n        i++;\r\n      }\r\n      blocks.push({ type: \"list\", ordered, items });\r\n      continue;\r\n    }\r\n\r\n    if (line.trim() === \"\") {\r\n      flushParagraph(paragraph);\r\n      i++;\r\n      continue;\r\n    }\r\n\r\n    paragraph.push(line);\r\n    i++;\r\n  }\r\n\r\n  flushParagraph(paragraph);\r\n  return blocks;\r\n}\r\n\r\nfunction splitRow(line: string): string[] {\r\n  return line\r\n    .trim()\r\n    .replace(/^\\|/, \"\")\r\n    .replace(/\\|$/, \"\")\r\n    .split(\"|\")\r\n    .map((cell) => cell.trim());\r\n}\r\n\r\n/* ------------------------------------------------------------------ *\r\n * Inline rendering\r\n * ------------------------------------------------------------------ */\r\n\r\nconst FILE_CHIP_RE = /\\[(NEW|MODIFY|DELETE)\\]\\s*`?([^`\\]\\s]+)`?/g;\r\n\r\nconst CHIP_STATUS: Record<string, FileStatus> = {\r\n  NEW: \"new\",\r\n  MODIFY: \"modified\",\r\n  DELETE: \"deleted\",\r\n};\r\n\r\nconst chipClass = css({\r\n  display: \"inline-flex\",\r\n  alignItems: \"center\",\r\n  gap: theme.space[1],\r\n  padding: `1px ${theme.space[1.5]} 1px ${theme.space[1]}`,\r\n  margin: \"0 1px\",\r\n  borderRadius: theme.radius.sm,\r\n  border: `1px solid ${theme.color.border}`,\r\n  backgroundColor: theme.color.muted,\r\n  fontFamily: theme.fontFamily.mono,\r\n  fontSize: \"0.8125em\",\r\n  verticalAlign: \"baseline\",\r\n  whiteSpace: \"nowrap\",\r\n});\r\n\r\nconst chipKindClass = css({\r\n  fontFamily: theme.fontFamily.sans,\r\n  fontSize: \"0.75em\",\r\n  fontWeight: theme.fontWeight.semibold,\r\n  letterSpacing: \"0.02em\",\r\n  textTransform: \"uppercase\",\r\n});\r\n\r\nconst CHIP_COLOR: Record<FileStatus, string> = {\r\n  new: theme.color.success,\r\n  modified: theme.color.warning,\r\n  deleted: theme.color.destructive,\r\n  unchanged: theme.color.mutedForeground,\r\n};\r\n\r\nconst codeInlineClass = css({\r\n  padding: `1px ${theme.space[1]}`,\r\n  borderRadius: theme.radius.sm,\r\n  backgroundColor: theme.color.muted,\r\n  fontFamily: theme.fontFamily.mono,\r\n  fontSize: \"0.875em\",\r\n});\r\n\r\nconst linkClass = css({\r\n  color: theme.color.primary,\r\n  textDecoration: \"underline\",\r\n  textUnderlineOffset: \"2px\",\r\n});\r\n\r\n/**\r\n * Renders inline markdown plus the `[NEW]`/`[MODIFY]`/`[DELETE] path` chips.\r\n *\r\n * File chips are matched first and the remaining text is then scanned for\r\n * inline markup, so a path containing `_` or `*` can't be mangled into\r\n * emphasis on its way through.\r\n */\r\nfunction renderInline(text: string, keyPrefix = \"\"): ReactNode[] {\r\n  const out: ReactNode[] = [];\r\n  let cursor = 0;\r\n  let key = 0;\r\n\r\n  FILE_CHIP_RE.lastIndex = 0;\r\n  let match: RegExpExecArray | null;\r\n\r\n  while ((match = FILE_CHIP_RE.exec(text)) !== null) {\r\n    if (match.index > cursor) out.push(...renderMarkup(text.slice(cursor, match.index), `${keyPrefix}m${key++}`));\r\n\r\n    const status = CHIP_STATUS[(match[1] as string).toUpperCase()] ?? \"unchanged\";\r\n    const path = match[2] as string;\r\n    out.push(\r\n      <span key={`${keyPrefix}c${key++}`} className={chipClass}>\r\n        <FileIcon filename={path} size={12} />\r\n        <span className={chipKindClass} style={{ color: CHIP_COLOR[status] }}>\r\n          {match[1]}\r\n        </span>\r\n        {path}\r\n      </span>,\r\n    );\r\n    cursor = match.index + match[0].length;\r\n  }\r\n\r\n  if (cursor < text.length) out.push(...renderMarkup(text.slice(cursor), `${keyPrefix}m${key++}`));\r\n  return out;\r\n}\r\n\r\nfunction renderMarkup(text: string, keyPrefix: string): ReactNode[] {\r\n  const out: ReactNode[] = [];\r\n  const pattern = /(`[^`]+`)|(\\*\\*[^*]+\\*\\*)|(__[^_]+__)|(\\*[^*\\n]+\\*)|(\\[[^\\]]*\\]\\([^)]*\\))/g;\r\n  let cursor = 0;\r\n  let key = 0;\r\n  let match: RegExpExecArray | null;\r\n\r\n  while ((match = pattern.exec(text)) !== null) {\r\n    if (match.index > cursor) out.push(text.slice(cursor, match.index));\r\n\r\n    if (match[1]) {\r\n      out.push(\r\n        <code key={`${keyPrefix}-${key++}`} className={codeInlineClass}>\r\n          {match[1].slice(1, -1)}\r\n        </code>,\r\n      );\r\n    } else if (match[2] || match[3]) {\r\n      const body = (match[2] ?? match[3]) as string;\r\n      out.push(<strong key={`${keyPrefix}-${key++}`}>{body.slice(2, -2)}</strong>);\r\n    } else if (match[4]) {\r\n      out.push(<em key={`${keyPrefix}-${key++}`}>{match[4].slice(1, -1)}</em>);\r\n    } else if (match[5]) {\r\n      const link = /\\[([^\\]]*)\\]\\(([^)]*)\\)/.exec(match[5]);\r\n      out.push(\r\n        <a key={`${keyPrefix}-${key++}`} className={linkClass} href={link?.[2] ?? \"#\"}>\r\n          {link?.[1] ?? \"\"}\r\n        </a>,\r\n      );\r\n    }\r\n    cursor = match.index + match[0].length;\r\n  }\r\n\r\n  if (cursor < text.length) out.push(text.slice(cursor));\r\n  return out;\r\n}\r\n\r\n/* ------------------------------------------------------------------ *\r\n * Block styles\r\n * ------------------------------------------------------------------ */\r\n\r\nconst rootClass = css({\r\n  fontFamily: theme.fontFamily.sans,\r\n  fontSize: theme.fontSize.sm,\r\n  lineHeight: theme.lineHeight.base,\r\n  color: theme.color.foreground,\r\n});\r\n\r\nconst headingClass = css({\r\n  fontWeight: theme.fontWeight.semibold,\r\n  letterSpacing: theme.letterSpacing.tight,\r\n  margin: `${theme.space[5]} 0 ${theme.space[2]}`,\r\n  \"&:first-child\": { marginTop: 0 },\r\n});\r\n\r\nconst HEADING_SIZE: Record<number, string> = {\r\n  1: theme.fontSize[\"2xl\"],\r\n  2: theme.fontSize.xl,\r\n  3: theme.fontSize.lg,\r\n  4: theme.fontSize.base,\r\n  5: theme.fontSize.sm,\r\n  6: theme.fontSize.xs,\r\n};\r\n\r\nconst paragraphClass = css({ margin: `0 0 ${theme.space[3]}` });\r\n\r\nconst listClass = css({\r\n  margin: `0 0 ${theme.space[3]}`,\r\n  paddingLeft: theme.space[5],\r\n  display: \"flex\",\r\n  flexDirection: \"column\",\r\n  gap: theme.space[1],\r\n});\r\n\r\nconst tableWrapClass = css({ overflowX: \"auto\", marginBottom: theme.space[4] });\r\n\r\nconst tableClass = css({\r\n  width: \"100%\",\r\n  borderCollapse: \"collapse\",\r\n  fontSize: theme.fontSize.sm,\r\n});\r\n\r\nconst thClass = css({\r\n  textAlign: \"left\",\r\n  padding: `${theme.space[2]} ${theme.space[3]}`,\r\n  borderBottom: `1px solid ${theme.color.border}`,\r\n  fontWeight: theme.fontWeight.semibold,\r\n  color: theme.color.mutedForeground,\r\n  fontSize: theme.fontSize.xs,\r\n  textTransform: \"uppercase\",\r\n  letterSpacing: \"0.03em\",\r\n  whiteSpace: \"nowrap\",\r\n});\r\n\r\nconst tdClass = css({\r\n  padding: `${theme.space[2]} ${theme.space[3]}`,\r\n  borderBottom: `1px solid ${theme.color.border}`,\r\n  verticalAlign: \"top\",\r\n});\r\n\r\nconst quoteClass = css({\r\n  margin: `0 0 ${theme.space[3]}`,\r\n  padding: `${theme.space[1]} 0 ${theme.space[1]} ${theme.space[3]}`,\r\n  borderLeft: `3px solid ${theme.color.border}`,\r\n  color: theme.color.mutedForeground,\r\n});\r\n\r\nconst ruleClass = css({\r\n  border: \"none\",\r\n  height: \"1px\",\r\n  backgroundColor: theme.color.border,\r\n  margin: `${theme.space[5]} 0`,\r\n});\r\n\r\nconst alertClass = css({\r\n  display: \"flex\",\r\n  gap: theme.space[2.5],\r\n  margin: `0 0 ${theme.space[4]}`,\r\n  padding: theme.space[3],\r\n  borderRadius: theme.radius.md,\r\n  border: \"1px solid\",\r\n  backgroundColor: theme.color.muted,\r\n});\r\n\r\nconst alertTitleClass = css({\r\n  fontWeight: theme.fontWeight.semibold,\r\n  fontSize: theme.fontSize.xs,\r\n  textTransform: \"uppercase\",\r\n  letterSpacing: \"0.03em\",\r\n  marginBottom: theme.space[1],\r\n});\r\n\r\nconst codeWrapClass = css({\r\n  marginBottom: theme.space[4],\r\n  borderRadius: theme.radius.md,\r\n  border: `1px solid ${theme.color.border}`,\r\n  backgroundColor: theme.color.muted,\r\n  overflow: \"hidden\",\r\n});\r\n\r\nconst codeHeaderClass = css({\r\n  padding: `${theme.space[1]} ${theme.space[3]}`,\r\n  borderBottom: `1px solid ${theme.color.border}`,\r\n  fontSize: theme.fontSize.xs,\r\n  color: theme.color.mutedForeground,\r\n  fontFamily: theme.fontFamily.mono,\r\n});\r\n\r\nconst codeBodyClass = css({ padding: theme.space[3] });\r\n\r\nconst mermaidClass = css({\r\n  marginBottom: theme.space[4],\r\n  padding: theme.space[3],\r\n  borderRadius: theme.radius.md,\r\n  border: `1px dashed ${theme.color.border}`,\r\n  backgroundColor: theme.color.muted,\r\n  fontFamily: theme.fontFamily.mono,\r\n  fontSize: theme.fontSize.xs,\r\n  color: theme.color.mutedForeground,\r\n  whiteSpace: \"pre-wrap\",\r\n  overflowX: \"auto\",\r\n});\r\n\r\nconst ALERT_COLOR: Record<AlertKind, string> = {\r\n  note: theme.color.primary,\r\n  tip: theme.color.success,\r\n  important: theme.color.primary,\r\n  warning: theme.color.warning,\r\n  caution: theme.color.destructive,\r\n};\r\n\r\n/* ------------------------------------------------------------------ *\r\n * Component\r\n * ------------------------------------------------------------------ */\r\n\r\nexport interface PlanDocumentProps {\r\n  /** Markdown source. Safe to pass a partial document mid-stream. */\r\n  source: string;\r\n  /**\r\n   * Renders a mermaid block. Left to the caller: bundling a diagram engine\r\n   * would dwarf this component and pull in the dependency the library exists\r\n   * without. Without it, the source is shown verbatim in a labeled block.\r\n   */\r\n  renderMermaid?: (code: string) => ReactNode;\r\n  /** Wraps each top-level section (a heading and the blocks under it). */\r\n  renderSection?: (section: { heading?: string; index: number; children: ReactNode }) => ReactNode;\r\n  className?: string;\r\n}\r\n\r\n/**\r\n * A streaming markdown renderer for agent plans: headings, tables, fenced\r\n * code (syntax-highlighted), GFM alerts, mermaid, and inline\r\n * `[NEW]`/`[MODIFY]`/`[DELETE] path` file chips.\r\n */\r\nexport function PlanDocument({ source, renderMermaid, renderSection, className }: PlanDocumentProps) {\r\n  const blocks = useMemo(() => parsePlan(source), [source]);\r\n\r\n  const rendered = blocks.map((block, index) => renderBlock(block, index, renderMermaid));\r\n\r\n  if (!renderSection) {\r\n    return <div className={className ? `${rootClass} ${className}` : rootClass}>{rendered}</div>;\r\n  }\r\n\r\n  // Group into sections at h1/h2 boundaries so a caller can wrap each one —\r\n  // which is what lets review-gate operate at \"section\" granularity.\r\n  const sections: Array<{ heading?: string; children: ReactNode[] }> = [];\r\n  blocks.forEach((block, index) => {\r\n    const isBoundary = block.type === \"heading\" && block.level <= 2;\r\n    if (isBoundary || sections.length === 0) {\r\n      sections.push({ heading: block.type === \"heading\" ? block.text : undefined, children: [] });\r\n    }\r\n    (sections[sections.length - 1] as { children: ReactNode[] }).children.push(rendered[index]);\r\n  });\r\n\r\n  return (\r\n    <div className={className ? `${rootClass} ${className}` : rootClass}>\r\n      {sections.map((section, index) => (\r\n        <div key={index}>{renderSection({ heading: section.heading, index, children: section.children })}</div>\r\n      ))}\r\n    </div>\r\n  );\r\n}\r\n\r\nfunction renderBlock(block: Block, index: number, renderMermaid?: (code: string) => ReactNode): ReactNode {\r\n  switch (block.type) {\r\n    case \"heading\": {\r\n      const Tag = `h${block.level}` as \"h1\";\r\n      return (\r\n        <Tag key={index} className={headingClass} style={{ fontSize: HEADING_SIZE[block.level] }}>\r\n          {renderInline(block.text, `h${index}`)}\r\n        </Tag>\r\n      );\r\n    }\r\n\r\n    case \"paragraph\":\r\n      return (\r\n        <p key={index} className={paragraphClass}>\r\n          {renderInline(block.text, `p${index}`)}\r\n        </p>\r\n      );\r\n\r\n    case \"list\": {\r\n      const Tag = block.ordered ? \"ol\" : \"ul\";\r\n      return (\r\n        <Tag key={index} className={listClass}>\r\n          {block.items.map((item, i) => (\r\n            <li key={i}>{renderInline(item, `l${index}-${i}`)}</li>\r\n          ))}\r\n        </Tag>\r\n      );\r\n    }\r\n\r\n    case \"table\":\r\n      return (\r\n        <div key={index} className={tableWrapClass}>\r\n          <table className={tableClass}>\r\n            <thead>\r\n              <tr>\r\n                {block.header.map((cell, i) => (\r\n                  <th key={i} className={thClass} style={{ textAlign: block.align[i] ?? \"left\" }}>\r\n                    {renderInline(cell, `th${index}-${i}`)}\r\n                  </th>\r\n                ))}\r\n              </tr>\r\n            </thead>\r\n            <tbody>\r\n              {block.rows.map((row, r) => (\r\n                <tr key={r}>\r\n                  {row.map((cell, c) => (\r\n                    <td key={c} className={tdClass} style={{ textAlign: block.align[c] ?? \"left\" }}>\r\n                      {renderInline(cell, `td${index}-${r}-${c}`)}\r\n                    </td>\r\n                  ))}\r\n                </tr>\r\n              ))}\r\n            </tbody>\r\n          </table>\r\n        </div>\r\n      );\r\n\r\n    case \"alert\":\r\n      return (\r\n        <div\r\n          key={index}\r\n          className={alertClass}\r\n          style={{ borderColor: ALERT_COLOR[block.kind] }}\r\n          role={block.kind === \"caution\" || block.kind === \"warning\" ? \"alert\" : undefined}\r\n        >\r\n          <div>\r\n            <div className={alertTitleClass} style={{ color: ALERT_COLOR[block.kind] }}>\r\n              {block.kind}\r\n            </div>\r\n            {block.lines.map((line, i) => (\r\n              <p key={i} className={paragraphClass} style={{ marginBottom: 0 }}>\r\n                {renderInline(line, `a${index}-${i}`)}\r\n              </p>\r\n            ))}\r\n          </div>\r\n        </div>\r\n      );\r\n\r\n    case \"quote\":\r\n      return (\r\n        <blockquote key={index} className={quoteClass}>\r\n          {block.lines.map((line, i) => (\r\n            <p key={i} className={paragraphClass} style={{ marginBottom: 0 }}>\r\n              {renderInline(line, `q${index}-${i}`)}\r\n            </p>\r\n          ))}\r\n        </blockquote>\r\n      );\r\n\r\n    case \"rule\":\r\n      return <hr key={index} className={ruleClass} />;\r\n\r\n    case \"mermaid\":\r\n      return (\r\n        <div key={index}>\r\n          {renderMermaid ? (\r\n            renderMermaid(block.code)\r\n          ) : (\r\n            <div className={mermaidClass} aria-label=\"Mermaid diagram source\">\r\n              {block.code}\r\n            </div>\r\n          )}\r\n        </div>\r\n      );\r\n\r\n    case \"code\": {\r\n      const lang = normalizeLang(block.lang);\r\n      return (\r\n        <div key={index} className={codeWrapClass}>\r\n          {block.lang ? <div className={codeHeaderClass}>{block.lang}</div> : null}\r\n          <div className={codeBodyClass}>\r\n            <Syntax code={block.code} language={lang} />\r\n          </div>\r\n        </div>\r\n      );\r\n    }\r\n\r\n    default:\r\n      return null;\r\n  }\r\n}\r\n\r\n/** Maps a fence info-string to a supported grammar, falling back sensibly. */\r\nfunction normalizeLang(lang: string | undefined): SyntaxLanguage {\r\n  if (!lang) return \"ts\";\r\n  const l = lang.toLowerCase();\r\n  const direct: Record<string, SyntaxLanguage> = {\r\n    ts: \"ts\",\r\n    typescript: \"ts\",\r\n    js: \"ts\",\r\n    javascript: \"ts\",\r\n    tsx: \"tsx\",\r\n    jsx: \"tsx\",\r\n    json: \"json\",\r\n    css: \"css\",\r\n    scss: \"css\",\r\n    sql: \"sql\",\r\n    md: \"md\",\r\n    markdown: \"md\",\r\n    yaml: \"yaml\",\r\n    yml: \"yaml\",\r\n    dockerfile: \"dockerfile\",\r\n    docker: \"dockerfile\",\r\n    env: \"env\",\r\n    dotenv: \"env\",\r\n    sh: \"sh\",\r\n    bash: \"sh\",\r\n    shell: \"sh\",\r\n    zsh: \"sh\",\r\n    console: \"sh\",\r\n  };\r\n  // An unrecognised info-string is often a filename (\"app/page.tsx\"), which\r\n  // the syntax component already knows how to map.\r\n  return direct[l] ?? languageFromFilename(l);\r\n}\r\n",
      "type": "registry:component"
    }
  ]
}