"use client"; import { createContext, isValidElement, useContext, type MouseEvent, type ReactNode } from "react"; import remarkGfm from "remark-gfm"; import remarkBreaks from "remark-breaks"; import remarkGemoji from "remark-gemoji"; import { InlineCode } from "@/components/task/chat/messages/inline-code"; import { CodeBlock } from "@/components/task/chat/messages/code-block"; import { MermaidBlock } from "@/components/shared/mermaid-block"; import { isMermaidContent } from "@/components/editors/tiptap/tiptap-mermaid-extension"; import { usePanelActions } from "@/hooks/use-panel-actions"; import { useAppStore } from "@/components/state-provider"; /** Shared remark plugins used by all markdown renderers */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export const remarkPlugins: any[] = [remarkGfm, remarkBreaks, remarkGemoji]; // `normalizeMarkdown` (pure string transform) and its cached variant live in // the React-free markdown cache module. Re-exported here so existing importers // keep working. export { normalizeMarkdown } from "@/lib/markdown/normalize-cache"; /** * Recursively extracts text content from React children. * Optimized with fast paths for common cases (string/number). */ export function getTextContent(children: ReactNode): string { if (typeof children === "string") return children; if (typeof children === "number") return String(children); if (children == null) return ""; if (Array.isArray(children)) { let result = ""; for (let i = 0; i < children.length; i++) { result += getTextContent(children[i]); } return result; } if (isValidElement(children)) { const props = children.props as { children?: ReactNode }; if (props.children) { return getTextContent(props.children); } } return ""; } type MarkdownCodeProps = { className?: string; children?: ReactNode; }; export type MarkdownFileLinkContextValue = { worktreePath?: string | null; onOpenFile?: (path: string) => void; }; export const MarkdownFileLinkContext = createContext({}); function isBlockCode(rawContent: string, hasLanguage: boolean): boolean { return hasLanguage || rawContent.includes("\n"); } const WEB_TLD_EXTENSIONS = new Set(["ai", "app", "cloud", "co", "com", "dev", "io", "net", "org"]); function looksLikeFilePath(path: string): boolean { const lastSegment = path.split("/").pop() ?? ""; if (!lastSegment.includes(".") || path.endsWith("/")) return false; const extension = lastSegment.split(".").pop() ?? ""; if (!/^[a-z0-9]{1,8}$/i.test(extension)) return false; return !WEB_TLD_EXTENSIONS.has(extension.toLowerCase()); } function isExternalHref(href: string): boolean { return /^[a-z][a-z\d+.-]*:/i.test(href) || href.startsWith("//"); } function stripHashAndQuery(href: string): string { return href.split(/[?#]/, 1)[0] ?? ""; } // Strip a trailing source-location / omp read selector so a file link resolves // to the bare path: ":42", ":42:5", and omp ranges like ":16-20", ":50+150", // ":5-16,960-973", ":2-4:raw", ":raw", ":conflicts". function stripSourceLocationSuffix(path: string): string { const part = String.raw`\d+(?:[-+]\d+)?(?:,\d+(?:[-+]\d+)?)*|raw|conflicts`; return path.replace(new RegExp(`:(?:${part})(?::(?:${part}))*$`), ""); } function decodeHrefPath(href: string): string | null { try { return stripSourceLocationSuffix(decodeURIComponent(stripHashAndQuery(href))); } catch { return null; } } function hasParentTraversal(path: string): boolean { return path.split("/").includes(".."); } function looksLikeHostAbsolutePath(path: string): boolean { return /^\/(?:[A-Za-z]:|Users|home|root|tmp|var|etc|usr|opt|mnt|Volumes)\//i.test(path); } function firstAbsoluteSegment(path: string): string | null { const first = path.replace(/^\/+/, "").split("/")[0]; return first || null; } function resolveAbsoluteMarkdownFileHref(path: string, worktreePath: string | null | undefined) { const normalizedRoot = worktreePath?.replace(/\\/g, "/").replace(/\/$/, ""); const normalizedPath = path.replace(/\\/g, "/"); if (normalizedRoot && normalizedPath.startsWith(`${normalizedRoot}/`)) { const relativePath = normalizedPath.slice(normalizedRoot.length + 1); return looksLikeFilePath(relativePath) ? relativePath : null; } if ( normalizedRoot && firstAbsoluteSegment(normalizedPath) === firstAbsoluteSegment(normalizedRoot) ) { return null; } if (looksLikeHostAbsolutePath(normalizedPath)) return null; const rootRelativePath = normalizedPath.replace(/^\/+/, ""); return looksLikeFilePath(rootRelativePath) ? rootRelativePath : null; } function resolveMarkdownFileHref( href: string | undefined, worktreePath: string | null | undefined, ) { if (!href || href.startsWith("#") || isExternalHref(href)) return null; const path = decodeHrefPath(href); if (!path || path.startsWith("~/") || hasParentTraversal(path)) return null; if (path.startsWith("/")) { return resolveAbsoluteMarkdownFileHref(path, worktreePath); } const normalizedPath = path.replace(/\\/g, "/").replace(/^\.\//, ""); if (normalizedPath.startsWith("../")) return null; return looksLikeFilePath(normalizedPath) ? normalizedPath : null; } type MarkdownLinkProps = { href?: string; children?: ReactNode; }; function MarkdownFileAnchor({ href, children, worktreePath, openFile, }: MarkdownLinkProps & { worktreePath: string | null | undefined; openFile: (path: string) => void; }) { const filePath = resolveMarkdownFileHref(href, worktreePath); const isInternal = !!filePath || href?.startsWith("/") || href?.startsWith("#"); const handleClick = filePath ? (event: MouseEvent) => { event.preventDefault(); openFile(filePath); } : undefined; return ( {children} ); } function MarkdownFallbackLink(props: MarkdownLinkProps) { const { openFile } = usePanelActions(); const worktreePath = useAppStore((state) => { const sessionId = state.tasks.activeSessionId; if (!sessionId) return null; return state.taskSessions.items[sessionId]?.worktree_path ?? null; }); return ; } function MarkdownLink(props: MarkdownLinkProps) { const linkContext = useContext(MarkdownFileLinkContext); if (linkContext.onOpenFile) { return ( ); } return ; } /** * Shared markdown component overrides for ReactMarkdown. * Element styles (headings, lists, inline code, etc.) are handled by * the `.markdown-body` CSS class in globals.css. Only behavioral overrides * (code routing, link target, table overflow wrapper) remain here. */ export const markdownComponents = { code: ({ className, children }: MarkdownCodeProps) => { const rawContent = getTextContent(children); const content = rawContent.replace(/\n$/, ""); const lang = className?.replace("language-", "") ?? null; const hasLanguage = className?.startsWith("language-") ?? false; const isBlock = isBlockCode(rawContent, hasLanguage); if (isBlock && isMermaidContent(lang, content)) { return ; } if (isBlock) { return {content}; } return {content}; }, a: MarkdownLink, table: ({ children }: { children?: ReactNode }) => (
{children}
), };