Improve links in markdown renderer (vibe-kanban) (#833)

* 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 `<span>` 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.
This commit is contained in:
Louis Knight-Webb
2025-09-24 11:56:06 +01:00
committed by GitHub
parent 018006c728
commit 875c5ed792

View File

@@ -10,6 +10,103 @@ import { Button } from '@/components/ui/button.tsx';
import { Check, Clipboard } from 'lucide-react'; import { Check, Clipboard } from 'lucide-react';
import { writeClipboardViaBridge } from '@/vscode/bridge'; 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 (
<span
role="link"
aria-disabled="true"
title={title || rawHref || undefined}
className={`${HIGHLIGHT_LINK} cursor-not-allowed select-text`}
>
{children}
</span>
);
}
// External link
return (
<a
href={safeHref}
title={title}
target="_blank"
rel="noopener noreferrer"
className={`${HIGHLIGHT_LINK} ${HIGHLIGHT_LINK_HOVER} underline`}
onClick={(e) => {
e.stopPropagation();
}}
>
{children}
</a>
);
}
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 <code>; leave className as-is for syntax highlighting
return (
<code {...props} className={className}>
{children}
</code>
);
}
return (
<code
{...props}
className={`${HIGHLIGHT_CODE}${className ? ` ${className}` : ''}`}
>
{children}
</code>
);
}
interface MarkdownRendererProps { interface MarkdownRendererProps {
content: string; content: string;
className?: string; className?: string;
@@ -23,16 +120,8 @@ function MarkdownRenderer({
}: MarkdownRendererProps) { }: MarkdownRendererProps) {
const overrides = useMemo( const overrides = useMemo(
() => ({ () => ({
code: { a: { component: LinkOverride },
component: ({ children, ...props }: any) => ( code: { component: InlineCodeOverride },
<code
{...props}
className="bg-background px-1 py-0.5 text-sm font-mono"
>
{children}
</code>
),
},
strong: { strong: {
component: ({ children, ...props }: any) => ( component: ({ children, ...props }: any) => (
<span {...props} className=""> <span {...props} className="">