From 875c5ed79259a577131c9bfb3e7a2fe0cc02f188 Mon Sep 17 00:00:00 2001 From: Louis Knight-Webb Date: Wed, 24 Sep 2025 11:56:06 +0100 Subject: [PATCH] Improve links in markdown renderer (vibe-kanban) (#833) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Perfect! The implementation is complete and all type checks pass. ## Summary I've successfully implemented all the requested improvements to the markdown renderer: ✅ **External links** now open in a new tab with `target="_blank"` and `rel="noopener noreferrer"` for security ✅ **Internal links** are disabled and rendered as `` elements with proper accessibility attributes ✅ **Link styling** includes highlighted background with hover effects for external links ✅ **File paths in backticks** now have similar highlighting to links ✅ **Security** includes URL sanitization to prevent dangerous protocols like `javascript:` and `data:` The implementation follows React and accessibility best practices, integrates seamlessly with the existing `markdown-to-jsx` setup, and maintains clean separation between external and internal link behaviors. * Cleanup script changes for task attempt 66cb18c3-0718-4c0f-803a-a3c8cb15d052 * Perfect! The change is complete and all checks pass. ## Summary ✅ **HTTPS-only external links** - Modified both `sanitizeHref()` and `isExternalHref()` functions to only allow `https://` URLs as external links Now only HTTPS links will open in new tabs, while HTTP links will be treated as disabled internal links (rendered as non-interactive spans). This provides better security by preventing insecure HTTP requests from the application. --- .../src/components/ui/markdown-renderer.tsx | 109 ++++++++++++++++-- 1 file changed, 99 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/ui/markdown-renderer.tsx b/frontend/src/components/ui/markdown-renderer.tsx index a0786867..81f0a96e 100644 --- a/frontend/src/components/ui/markdown-renderer.tsx +++ b/frontend/src/components/ui/markdown-renderer.tsx @@ -10,6 +10,103 @@ import { Button } from '@/components/ui/button.tsx'; import { Check, Clipboard } from 'lucide-react'; import { writeClipboardViaBridge } from '@/vscode/bridge'; +const HIGHLIGHT_LINK = + 'rounded-sm bg-muted/50 px-1 py-0.5 underline-offset-2 transition-colors'; +const HIGHLIGHT_LINK_HOVER = 'hover:bg-muted'; +const HIGHLIGHT_CODE = 'rounded-sm bg-muted/50 px-1 py-0.5 font-mono text-sm'; + +function sanitizeHref(href?: string): string | undefined { + if (typeof href !== 'string') return undefined; + const trimmed = href.trim(); + // Block dangerous protocols + if (/^(javascript|vbscript|data):/i.test(trimmed)) return undefined; + // Allow anchors and common relative forms + if ( + trimmed.startsWith('#') || + trimmed.startsWith('./') || + trimmed.startsWith('../') || + trimmed.startsWith('/') + ) + return trimmed; + // Allow only https + if (/^https:\/\//i.test(trimmed)) return trimmed; + // Block everything else by default + return undefined; +} + +function isExternalHref(href?: string): boolean { + if (!href) return false; + return /^https:\/\//i.test(href); +} + +function LinkOverride({ + href, + children, + title, +}: { + href?: string; + children: React.ReactNode; + title?: string; +}) { + const rawHref = typeof href === 'string' ? href : ''; + const safeHref = sanitizeHref(rawHref); + + const external = isExternalHref(safeHref); + const internalOrDisabled = !external; + + if (!safeHref || internalOrDisabled) { + // Disabled internal link (relative paths and anchors) + return ( + + {children} + + ); + } + + // External link + return ( + { + e.stopPropagation(); + }} + > + {children} + + ); +} + +function InlineCodeOverride({ children, className, ...props }: any) { + // Only highlight inline code, not fenced code blocks + const hasLanguage = + typeof className === 'string' && /\blanguage-/.test(className); + if (hasLanguage) { + // Likely a fenced block's ; leave className as-is for syntax highlighting + return ( + + {children} + + ); + } + return ( + + {children} + + ); +} + interface MarkdownRendererProps { content: string; className?: string; @@ -23,16 +120,8 @@ function MarkdownRenderer({ }: MarkdownRendererProps) { const overrides = useMemo( () => ({ - code: { - component: ({ children, ...props }: any) => ( - - {children} - - ), - }, + a: { component: LinkOverride }, + code: { component: InlineCodeOverride }, strong: { component: ({ children, ...props }: any) => (