diff --git a/apps/web/components/shared/markdown-components.test.tsx b/apps/web/components/shared/markdown-components.test.tsx new file mode 100644 index 0000000000..f94d747a06 --- /dev/null +++ b/apps/web/components/shared/markdown-components.test.tsx @@ -0,0 +1,55 @@ +import type { ReactNode } from "react"; +import ReactMarkdown from "react-markdown"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import { markdownComponents, remarkPlugins } from "./markdown-components"; + +vi.mock("@/components/shared/mermaid-block", () => ({ + MermaidBlock: ({ code }: { code: string }) =>
{code}
, +})); + +vi.mock("@/components/task/chat/messages/code-block", () => ({ + CodeBlock: ({ children, className }: { children: ReactNode; className?: string }) => ( +
+      {children}
+    
+ ), +})); + +vi.mock("@/components/task/chat/messages/inline-code", () => ({ + InlineCode: ({ children }: { children: ReactNode }) => {children}, +})); + +function renderMarkdown(source: string): string { + return renderToStaticMarkup( + + {source} + , + ); +} + +describe("markdownComponents", () => { + it("keeps mermaid keywords in inline code as inline code", () => { + const html = renderMarkdown("Metadata comes from `kanban`, `kanbanMulti`, repositories."); + + expect(html).toContain('data-kind="inline"'); + expect(html).toContain("kanban"); + expect(html).not.toContain('data-kind="mermaid"'); + }); + + it("renders fenced mermaid code as a mermaid block", () => { + const html = renderMarkdown("```mermaid\ngraph LR\nA-->B\n```"); + + expect(html).toContain('data-kind="mermaid"'); + expect(html).toContain("graph LR"); + }); + + it("renders non-mermaid fenced code as a code block", () => { + const html = renderMarkdown("```ts\nconst source = 'kanban';\n```"); + + expect(html).toContain('data-kind="block"'); + expect(html).toContain("language-ts"); + expect(html).toContain("const source"); + expect(html).not.toContain('data-kind="mermaid"'); + }); +}); diff --git a/apps/web/components/shared/markdown-components.tsx b/apps/web/components/shared/markdown-components.tsx index e6dd392511..d31a3e1304 100644 --- a/apps/web/components/shared/markdown-components.tsx +++ b/apps/web/components/shared/markdown-components.tsx @@ -39,6 +39,15 @@ export function getTextContent(children: ReactNode): string { return ""; } +type MarkdownCodeProps = { + className?: string; + children?: ReactNode; +}; + +function isBlockCode(rawContent: string, hasLanguage: boolean): boolean { + return hasLanguage || rawContent.includes("\n"); +} + /** * Shared markdown component overrides for ReactMarkdown. * Element styles (headings, lists, inline code, etc.) are handled by @@ -46,15 +55,16 @@ export function getTextContent(children: ReactNode): string { * (code routing, link target, table overflow wrapper) remain here. */ export const markdownComponents = { - code: ({ className, children }: { className?: string; children?: ReactNode }) => { - const content = getTextContent(children).replace(/\n$/, ""); + code: ({ className, children }: MarkdownCodeProps) => { + const rawContent = getTextContent(children); + const content = rawContent.replace(/\n$/, ""); const lang = className?.replace("language-", "") ?? null; - if (isMermaidContent(lang, content)) { + const hasLanguage = className?.startsWith("language-") ?? false; + const isBlock = isBlockCode(rawContent, hasLanguage); + if (isBlock && isMermaidContent(lang, content)) { return ; } - const hasLanguage = className?.startsWith("language-"); - const hasNewlines = content.includes("\n"); - if (hasLanguage || hasNewlines) { + if (isBlock) { return {content}; } return {content};