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:
committed by
GitHub
parent
018006c728
commit
875c5ed792
@@ -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="">
|
||||||
|
|||||||
Reference in New Issue
Block a user