feat: theme-adaptive SVG support for custom MCP and group icons

Custom icons (MCP server iconPath, model spec groupIcon) were rendered as
plain <img>, so monochrome SVGs kept fixed dark colors and were nearly
invisible in dark theme.

Introduce a shared CustomIcon component that detects monochrome SVG glyphs
and tints them with currentColor so they follow the active theme, while
multi-color SVG logos and raster images keep their original colors. The
monochrome decision parses the SVG's color tokens; content is fetched once,
cached, and any failure falls back to the original image. Monochrome SVGs
render via CSS mask, never inlined, so no SVG markup reaches the DOM.

Apply across all custom-icon surfaces: MCP settings cards, the chat MCP
dropdown, stacked MCP icons, tool-call headers, and model group icons.

Also support SVG in the MCP avatar uploader: add SVG to the accepted file
types and sanitize uploaded SVGs with DOMPurify before storing them, and
make the dialog preview theme-adaptive via the same component.

Add unit tests for SVG detection, monochrome analysis, sanitization, and
CustomIcon rendering.
This commit is contained in:
Marco Beretta 2026-06-17 15:36:28 +02:00
parent a6b5343220
commit 40d2f682a4
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
17 changed files with 494 additions and 44 deletions

View file

@ -2,6 +2,7 @@ import React, { memo, useState } from 'react';
import { AlertCircle } from 'lucide-react';
import type { IconMapProps } from '~/common';
import { getKnownEndpointAsset, hasKnownEndpointIcon } from '~/hooks/Endpoint/UnknownIcon';
import CustomIcon from '~/components/ui/CustomIcon';
import { icons } from '~/hooks/Endpoint/Icons';
interface GroupIconProps {
@ -59,10 +60,10 @@ const GroupIcon: React.FC<GroupIconProps> = ({ iconURL, groupName }) => {
return (
<div
className="icon-md shrink-0 overflow-hidden rounded-full"
className="icon-md shrink-0 overflow-hidden rounded-full text-text-primary"
style={{ width: 20, height: 20 }}
>
<img
<CustomIcon
src={resolvedIconURL || iconURL}
alt={groupName}
className="h-full w-full object-cover"

View file

@ -11,6 +11,7 @@ import {
Wrench,
} from 'lucide-react';
import LangIcon from '~/components/Messages/Content/LangIcon';
import CustomIcon from '~/components/ui/CustomIcon';
import { cn } from '~/utils';
function BashIcon({ className }: { className?: string }) {
@ -104,15 +105,14 @@ interface ToolIconProps {
export default function ToolIcon({ type, iconUrl, isAnimating = false, className }: ToolIconProps) {
if (iconUrl) {
return (
<img
<CustomIcon
src={iconUrl}
alt=""
className={cn(
'size-4 shrink-0 rounded-full object-cover',
'size-4 shrink-0 rounded-full object-cover text-text-secondary',
isAnimating && 'animate-pulse',
className,
)}
aria-hidden="true"
/>
);
}

View file

@ -1,15 +1,16 @@
import * as Ariakit from '@ariakit/react';
import { Check } from 'lucide-react';
import * as Ariakit from '@ariakit/react';
import { MCPIcon } from '@librechat/client';
import type { MCPServerDefinition } from '~/hooks/MCP/useMCPServerManager';
import type { MCPServerStatusIconProps } from './MCPServerStatusIcon';
import MCPServerStatusIcon from './MCPServerStatusIcon';
import {
getStatusColor,
getStatusTextKey,
shouldShowActionButton,
type ConnectionStatusMap,
} from './mcpServerUtils';
import MCPServerStatusIcon from './MCPServerStatusIcon';
import CustomIcon from '~/components/ui/CustomIcon';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -58,9 +59,9 @@ export default function MCPServerMenuItem({
{/* Server Icon with Status Dot */}
<div className="relative flex-shrink-0">
{server.config?.iconPath ? (
<img
<CustomIcon
src={server.config.iconPath}
className="h-8 w-8 rounded-lg object-cover"
className="h-8 w-8 rounded-lg object-cover text-text-primary"
alt={displayName}
/>
) : (

View file

@ -2,6 +2,7 @@ import { useMemo } from 'react';
import { MCPIcon } from '@librechat/client';
import type { MCPServerDefinition } from '~/hooks/MCP/useMCPServerManager';
import { getSelectedServerIcons } from './mcpServerUtils';
import CustomIcon from '~/components/ui/CustomIcon';
import { cn } from '~/utils';
interface StackedMCPIconsProps {
@ -74,10 +75,10 @@ export default function StackedMCPIcons({
style={{ zIndex: icons.length - index }}
>
{icon.iconPath ? (
<img
<CustomIcon
src={icon.iconPath}
alt={icon.displayName}
className={cn('rounded-full object-cover', sizes.icon)}
className={cn('rounded-full object-cover text-text-primary', sizes.icon)}
/>
) : (
<MCPIcon className={cn('text-text-primary', sizes.icon)} />

View file

@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { useRef } from 'react';
import { SquirclePlusIcon } from '@librechat/client';
import CustomIcon from '~/components/ui/CustomIcon';
import { useLocalize } from '~/hooks';
interface MCPIconProps {
@ -8,18 +9,9 @@ interface MCPIconProps {
}
export default function MCPIcon({ icon, onIconChange }: MCPIconProps) {
const [previewUrl, setPreviewUrl] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const localize = useLocalize();
useEffect(() => {
if (icon) {
setPreviewUrl(icon);
} else {
setPreviewUrl('');
}
}, [icon]);
const handleClick = () => {
if (fileInputRef.current) {
fileInputRef.current.value = '';
@ -44,13 +36,11 @@ export default function MCPIcon({ icon, onIconChange }: MCPIconProps) {
aria-label={localize('com_ui_upload_icon')}
className="bg-token-surface-secondary dark:bg-token-surface-tertiary border-token-border-medium flex h-16 w-16 shrink-0 cursor-pointer items-center justify-center rounded-xl border-2 border-dashed focus:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy"
>
{previewUrl ? (
<img
src={previewUrl}
className="h-full w-full rounded-xl object-cover"
alt="MCP Icon"
width="64"
height="64"
{icon ? (
<CustomIcon
src={icon}
alt={localize('com_ui_icon')}
className="h-full w-full rounded-xl object-cover text-text-primary"
/>
) : (
<SquirclePlusIcon />
@ -63,7 +53,7 @@ export default function MCPIcon({ icon, onIconChange }: MCPIconProps) {
<span className="text-xs text-text-secondary">{localize('com_agents_mcp_icon_size')}</span>
</div>
<input
accept="image/png,.png,image/jpeg,.jpg,.jpeg,image/gif,.gif,image/webp,.webp"
accept="image/png,.png,image/jpeg,.jpg,.jpeg,image/gif,.gif,image/webp,.webp,image/svg+xml,.svg"
multiple={false}
type="file"
style={{ display: 'none' }}

View file

@ -3,10 +3,11 @@ import { MCPIcon } from '@librechat/client';
import { PermissionBits, hasPermissions } from 'librechat-data-provider';
import type { MCPServerStatusIconProps } from '~/components/MCP/MCPServerStatusIcon';
import type { MCPServerDefinition } from '~/hooks';
import MCPServerDialog from './MCPServerDialog';
import { getStatusDotColor } from './MCPStatusBadge';
import MCPCardActions from './MCPCardActions';
import { useMCPServerManager, useLocalize } from '~/hooks';
import { getStatusDotColor } from './MCPStatusBadge';
import CustomIcon from '~/components/ui/CustomIcon';
import MCPServerDialog from './MCPServerDialog';
import MCPCardActions from './MCPCardActions';
import { cn } from '~/utils';
interface MCPServerCardProps {
@ -98,11 +99,10 @@ export default function MCPServerCard({
{/* Server Icon with Status Dot */}
<div className="relative flex-shrink-0">
{server.config?.iconPath ? (
<img
<CustomIcon
src={server.config.iconPath}
className="size-8 rounded-lg object-cover"
className="size-8 rounded-lg object-cover text-text-primary"
alt=""
aria-hidden="true"
/>
) : (
<div className="flex size-8 items-center justify-center rounded-lg bg-surface-tertiary">

View file

@ -2,8 +2,8 @@ import { useFormContext } from 'react-hook-form';
import { Input, Label, Textarea } from '@librechat/client';
import type { MCPServerFormData } from '../hooks/useMCPServerForm';
import MCPIcon from '~/components/SidePanel/Agents/MCPIcon';
import { cn, sanitizeSvg, svgToDataUri } from '~/utils';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
export default function BasicInfoSection() {
const localize = useLocalize();
@ -18,14 +18,25 @@ export default function BasicInfoSection() {
const handleIconChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
const base64String = reader.result as string;
setValue('icon', base64String);
};
reader.readAsDataURL(file);
if (!file) {
return;
}
const reader = new FileReader();
const isSvg = file.type === 'image/svg+xml' || /\.svg$/i.test(file.name);
if (isSvg) {
reader.onloadend = () => {
const sanitized = sanitizeSvg(reader.result as string);
setValue('icon', svgToDataUri(sanitized));
};
reader.readAsText(file);
return;
}
reader.onloadend = () => {
setValue('icon', reader.result as string);
};
reader.readAsDataURL(file);
};
return (

View file

@ -0,0 +1,55 @@
import type { ReactEventHandler } from 'react';
import useAdaptiveIcon from '~/hooks/useAdaptiveIcon';
import { cn } from '~/utils';
interface CustomIconProps {
src: string;
alt?: string;
className?: string;
onError?: ReactEventHandler<HTMLImageElement>;
}
/**
* Renders a user-provided custom icon (e.g. an MCP server `iconPath` or a model
* group `groupIcon`). Monochrome SVG glyphs are masked with `currentColor` so
* they follow the active theme, while raster images and multi-color SVG logos
* keep their original colors. The tint color is inherited from the element's
* text color, so set a `text-*` class on `className`.
*/
export default function CustomIcon({ src, alt = '', className, onError }: CustomIconProps) {
const { shouldTint } = useAdaptiveIcon(src);
const decorative = alt === '';
if (shouldTint) {
const maskUrl = `url("${src.replace(/"/g, '%22')}")`;
return (
<span
role={decorative ? undefined : 'img'}
aria-label={decorative ? undefined : alt}
aria-hidden={decorative ? true : undefined}
className={cn('inline-block', className)}
style={{
backgroundColor: 'currentColor',
maskImage: maskUrl,
WebkitMaskImage: maskUrl,
maskRepeat: 'no-repeat',
WebkitMaskRepeat: 'no-repeat',
maskPosition: 'center',
WebkitMaskPosition: 'center',
maskSize: 'contain',
WebkitMaskSize: 'contain',
}}
/>
);
}
return (
<img
src={src}
alt={alt}
aria-hidden={decorative ? true : undefined}
className={className}
onError={onError}
/>
);
}

View file

@ -0,0 +1,21 @@
import { render, screen } from '@testing-library/react';
import CustomIcon from '../CustomIcon';
describe('CustomIcon', () => {
it('renders a raster image with its source and accessible label', () => {
render(<CustomIcon src="/assets/logo.png" alt="My Server" className="size-8" />);
const img = screen.getByRole('img', { name: 'My Server' });
expect(img.tagName).toBe('IMG');
expect(img).toHaveAttribute('src', '/assets/logo.png');
});
it('hides decorative raster images from assistive technology', () => {
const { container } = render(<CustomIcon src="/assets/logo.png" alt="" />);
const img = container.querySelector('img');
expect(img).not.toBeNull();
expect(img).toHaveAttribute('aria-hidden', 'true');
expect(img).toHaveAttribute('src', '/assets/logo.png');
});
});

View file

@ -1,4 +1,5 @@
export { Button } from '@librechat/client';
export { default as CustomIcon } from './CustomIcon';
export { default as TermsAndConditionsModal } from './TermsAndConditionsModal';
export { default as AdminSettingsDialog } from './AdminSettingsDialog';
export type { PermissionConfig, AdminSettingsDialogProps } from './AdminSettingsDialog';

View file

@ -31,6 +31,7 @@ export { default as useFocusTrap } from './useFocusTrap';
export { default as useFavorites } from './useFavorites';
export { default as useSkillFavorites } from './useSkillFavorites';
export { default as useChatBadges } from './useChatBadges';
export { default as useAdaptiveIcon } from './useAdaptiveIcon';
export { default as useScrollToRef } from './useScrollToRef';
export { default as useIsActiveItem } from './useIsActiveItem';
export { default as useLocalStorage } from './useLocalStorage';

View file

@ -0,0 +1,84 @@
import { useEffect, useState } from 'react';
import { isSvgIcon, isMonochromeSvg } from '~/utils';
/** Resolved monochrome verdicts, keyed by icon source, shared across instances. */
const monochromeCache = new Map<string, boolean>();
/** In-flight resolutions, so concurrent instances of the same icon fetch once. */
const inFlight = new Map<string, Promise<boolean>>();
function decodeDataUri(uri: string): string {
const comma = uri.indexOf(',');
if (comma === -1) {
return '';
}
const meta = uri.slice(0, comma);
const content = uri.slice(comma + 1);
if (/;base64/i.test(meta)) {
return atob(content);
}
return decodeURIComponent(content);
}
async function loadSvgContent(src: string): Promise<string> {
if (src.startsWith('data:')) {
return decodeDataUri(src);
}
const response = await fetch(src);
if (!response.ok) {
throw new Error(`Failed to load SVG icon (${response.status})`);
}
return response.text();
}
function resolveMonochrome(src: string): Promise<boolean> {
const cached = monochromeCache.get(src);
if (cached !== undefined) {
return Promise.resolve(cached);
}
const existing = inFlight.get(src);
if (existing) {
return existing;
}
const promise = loadSvgContent(src)
.then(isMonochromeSvg)
.catch(() => false)
.then((monochrome) => {
monochromeCache.set(src, monochrome);
inFlight.delete(src);
return monochrome;
});
inFlight.set(src, promise);
return promise;
}
/**
* Determines whether a custom icon should be tinted to `currentColor` so it
* adapts to the active theme. Only monochrome SVG glyphs are tinted; raster
* images and multi-color SVG logos keep their original colors. SVG content is
* fetched once and cached; any fetch failure (e.g. CORS) leaves the icon
* untinted.
*/
export default function useAdaptiveIcon(src?: string | null): { shouldTint: boolean } {
const key = isSvgIcon(src) ? src : null;
const [isMonochrome, setIsMonochrome] = useState<boolean>(() =>
key != null ? (monochromeCache.get(key) ?? false) : false,
);
useEffect(() => {
if (key == null) {
setIsMonochrome(false);
return;
}
let active = true;
resolveMonochrome(key).then((monochrome) => {
if (active) {
setIsMonochrome(monochrome);
}
});
return () => {
active = false;
};
}, [key]);
return { shouldTint: isMonochrome };
}

View file

@ -1,4 +1,4 @@
import { isImageURL } from '../icons';
import { isImageURL, isSvgIcon } from '../icons';
describe('isImageURL', () => {
it.each(['https://example.com/icon.png', 'http://example.com/icon.png', '/assets/icon.svg'])(
@ -15,3 +15,29 @@ describe('isImageURL', () => {
},
);
});
describe('isSvgIcon', () => {
it.each([
'https://example.com/icon.svg',
'/assets/icon.svg',
'/assets/icon.SVG',
'https://example.com/icon.svg?v=2',
'https://example.com/icon.svg#hash',
'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=',
'data:image/svg+xml,%3Csvg%3E%3C/svg%3E',
])('accepts SVG icon %s', (iconURL) => {
expect(isSvgIcon(iconURL)).toBe(true);
});
it.each([
'https://example.com/icon.png',
'/assets/icon.jpg',
'data:image/png;base64,abc',
'https://example.com/svg-logo.png',
'',
null,
undefined,
])('rejects non-SVG icon %s', (iconURL) => {
expect(isSvgIcon(iconURL)).toBe(false);
});
});

View file

@ -0,0 +1,110 @@
import { isMonochromeSvg, sanitizeSvg } from '../svg';
describe('isMonochromeSvg', () => {
describe('monochrome icons (tinted to currentColor)', () => {
it('treats an SVG with no explicit colors as monochrome (default black fill)', () => {
const svg = '<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z" /></svg>';
expect(isMonochromeSvg(svg)).toBe(true);
});
it('treats a black fill as monochrome', () => {
const svg = '<svg><path fill="#000000" d="M0 0h10v10H0z" /></svg>';
expect(isMonochromeSvg(svg)).toBe(true);
});
it('treats shorthand black hex as monochrome', () => {
const svg = '<svg><path fill="#000" /></svg>';
expect(isMonochromeSvg(svg)).toBe(true);
});
it('treats grayscale shades as monochrome', () => {
const svg = '<svg><path fill="#333" /><path stroke="#666666" /><rect fill="#ccc" /></svg>';
expect(isMonochromeSvg(svg)).toBe(true);
});
it('treats named grayscale colors as monochrome', () => {
const svg = '<svg><path fill="black" /><path stroke="gray" /></svg>';
expect(isMonochromeSvg(svg)).toBe(true);
});
it('treats grayscale rgb() as monochrome', () => {
const svg = '<svg><path style="fill: rgb(50, 50, 50)" /></svg>';
expect(isMonochromeSvg(svg)).toBe(true);
});
it('treats grayscale hsl() (zero saturation) as monochrome', () => {
const svg = '<svg><path style="fill: hsl(0, 0%, 20%)" /></svg>';
expect(isMonochromeSvg(svg)).toBe(true);
});
it('ignores none/transparent and currentColor', () => {
const svg =
'<svg><path fill="none" stroke="currentColor" /><rect fill="transparent" /></svg>';
expect(isMonochromeSvg(svg)).toBe(true);
});
it('handles colors defined inside a style block', () => {
const svg = '<svg><style>.a{fill:#222}.b{stroke:#888}</style><path class="a" /></svg>';
expect(isMonochromeSvg(svg)).toBe(true);
});
});
describe('multi-color icons (colors preserved)', () => {
it('treats a saturated hex color as multi-color', () => {
const svg = '<svg><path fill="#ff0000" /></svg>';
expect(isMonochromeSvg(svg)).toBe(false);
});
it('treats a mix of saturated colors as multi-color', () => {
const svg =
'<svg><path fill="#4285F4" /><path fill="#34A853" /><path fill="#EA4335" /></svg>';
expect(isMonochromeSvg(svg)).toBe(false);
});
it('treats named chromatic colors as multi-color', () => {
const svg = '<svg><path fill="rebeccapurple" /></svg>';
expect(isMonochromeSvg(svg)).toBe(false);
});
it('treats saturated rgb() as multi-color', () => {
const svg = '<svg><path style="fill: rgb(255, 0, 0)" /></svg>';
expect(isMonochromeSvg(svg)).toBe(false);
});
it('treats saturated hsl() as multi-color', () => {
const svg = '<svg><path style="fill: hsl(210, 80%, 50%)" /></svg>';
expect(isMonochromeSvg(svg)).toBe(false);
});
it('treats gradient (url reference) fills as multi-color', () => {
const svg = '<svg><path fill="url(#grad)" /></svg>';
expect(isMonochromeSvg(svg)).toBe(false);
});
});
});
describe('sanitizeSvg', () => {
it('strips script tags but keeps drawing elements', () => {
const dirty = '<svg><script>alert(1)</script><path d="M0 0h10v10H0z" /></svg>';
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain('<script');
expect(clean).not.toContain('alert(1)');
expect(clean).toContain('path');
});
it('strips inline event handler attributes', () => {
const dirty = '<svg onload="alert(1)"><circle cx="5" cy="5" r="5" onclick="alert(2)" /></svg>';
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain('onload');
expect(clean).not.toContain('onclick');
expect(clean).toContain('circle');
});
it('removes foreignObject and embedded HTML', () => {
const dirty =
'<svg><foreignObject><iframe src="javascript:alert(1)"></iframe></foreignObject></svg>';
const clean = sanitizeSvg(dirty);
expect(clean.toLowerCase()).not.toContain('foreignobject');
expect(clean.toLowerCase()).not.toContain('iframe');
});
});

View file

@ -5,3 +5,16 @@ export function isImageURL(iconURL?: string | null): iconURL is string {
return /^https?:\/\//i.test(iconURL) || (iconURL.startsWith('/') && !iconURL.startsWith('//'));
}
export function isSvgIcon(iconURL?: string | null): iconURL is string {
if (!iconURL) {
return false;
}
if (/^data:image\/svg\+xml/i.test(iconURL)) {
return true;
}
const path = iconURL.split(/[?#]/)[0];
return /\.svg$/i.test(path);
}

View file

@ -6,6 +6,7 @@ import logger from './logger';
export * from './map';
export * from './json';
export * from './icons';
export * from './svg';
export * from './email';
export * from './share';
export * from './files';

134
client/src/utils/svg.ts Normal file
View file

@ -0,0 +1,134 @@
import DOMPurify from 'dompurify';
/**
* Heuristics for deciding whether a custom SVG icon is a monochrome glyph that
* should be tinted to `currentColor` (so it follows the active theme) or a
* multi-color logo that must keep its original colors.
*/
const COLOR_REGEX =
/(?:fill|stroke|stop-color|flood-color|lighting-color|color)\s*[:=]\s*["']?\s*(#[0-9a-fA-F]{3,8}|rgba?\([^)]*\)|hsla?\([^)]*\)|[a-zA-Z]+)/gi;
/** Color keywords that carry no chromatic information and are ignored. */
const IGNORABLE_COLORS = new Set(['none', 'transparent', 'inherit', 'currentcolor']);
/** Named CSS colors that are pure grayscale. Unknown names are treated as chromatic. */
const GRAY_NAMES = new Set([
'black',
'white',
'gray',
'grey',
'silver',
'gainsboro',
'whitesmoke',
'lightgray',
'lightgrey',
'darkgray',
'darkgrey',
'dimgray',
'dimgrey',
]);
function hexToRgb(hex: string): [number, number, number] | null {
let value = hex.slice(1);
if (value.length === 3 || value.length === 4) {
value = value
.split('')
.map((char) => char + char)
.join('');
}
if (value.length !== 6 && value.length !== 8) {
return null;
}
const r = parseInt(value.slice(0, 2), 16);
const g = parseInt(value.slice(2, 4), 16);
const b = parseInt(value.slice(4, 6), 16);
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) {
return null;
}
return [r, g, b];
}
function functionalToValues(color: string): number[] | null {
const open = color.indexOf('(');
const close = color.indexOf(')');
if (open === -1 || close === -1) {
return null;
}
const parts = color
.slice(open + 1, close)
.split(/[,/\s]+/)
.map((part) => part.trim())
.filter(Boolean);
if (parts.length < 3) {
return null;
}
const toNumber = (part: string) =>
part.endsWith('%') ? (parseFloat(part) / 100) * 255 : parseFloat(part);
const values = parts.slice(0, 3).map(toNumber);
return values.some(Number.isNaN) ? null : values;
}
function isGrayscaleColor(color: string): boolean {
if (color.startsWith('#')) {
const rgb = hexToRgb(color);
return rgb ? rgb[0] === rgb[1] && rgb[1] === rgb[2] : false;
}
if (color.startsWith('rgb')) {
const rgb = functionalToValues(color);
if (!rgb) {
return false;
}
const [r, g, b] = rgb.map(Math.round);
return r === g && g === b;
}
if (color.startsWith('hsl')) {
const hsl = functionalToValues(color);
return hsl ? hsl[1] === 0 : false;
}
return GRAY_NAMES.has(color);
}
function extractColors(svg: string): string[] {
const colors: string[] = [];
COLOR_REGEX.lastIndex = 0;
let match: RegExpExecArray | null = COLOR_REGEX.exec(svg);
while (match !== null) {
const token = match[1].trim().toLowerCase();
if (token && !IGNORABLE_COLORS.has(token)) {
colors.push(token);
}
match = COLOR_REGEX.exec(svg);
}
return colors;
}
/**
* Returns true when an SVG only contains grayscale colors (or relies on the
* default black fill / `currentColor`), meaning it can be safely tinted to match
* the theme. Multi-color logos return false so their colors are preserved.
*/
export function isMonochromeSvg(svg: string): boolean {
const colors = extractColors(svg);
if (colors.length === 0) {
return true;
}
return colors.every(isGrayscaleColor);
}
/**
* Strips scripts, event handlers, and other active content from user-provided
* SVG markup using an allowlist sanitizer, leaving only safe drawing elements.
*/
export function sanitizeSvg(svg: string): string {
return DOMPurify.sanitize(svg, {
USE_PROFILES: { svg: true, svgFilters: true },
FORBID_TAGS: ['script', 'foreignObject'],
FORBID_ATTR: ['onload', 'onerror', 'onclick', 'onmouseover', 'onmouseenter', 'onfocus'],
});
}
/** Encodes SVG markup as a URL-encoded `image/svg+xml` data URI. */
export function svgToDataUri(svg: string): string {
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}