From 40d2f682a400be71a61a8b8f0d6cea7577faedb8 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:36:28 +0200 Subject: [PATCH] feat: theme-adaptive SVG support for custom MCP and group icons Custom icons (MCP server iconPath, model spec groupIcon) were rendered as plain , 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. --- .../Menus/Endpoints/components/GroupIcon.tsx | 5 +- .../Messages/Content/ToolOutput/ToolIcon.tsx | 6 +- .../src/components/MCP/MCPServerMenuItem.tsx | 9 +- client/src/components/MCP/StackedMCPIcons.tsx | 5 +- .../components/SidePanel/Agents/MCPIcon.tsx | 26 ++-- .../SidePanel/MCPBuilder/MCPServerCard.tsx | 12 +- .../sections/BasicInfoSection.tsx | 27 ++-- client/src/components/ui/CustomIcon.tsx | 55 +++++++ .../ui/__tests__/CustomIcon.test.tsx | 21 +++ client/src/components/ui/index.ts | 1 + client/src/hooks/index.ts | 1 + client/src/hooks/useAdaptiveIcon.ts | 84 +++++++++++ client/src/utils/__tests__/icons.test.ts | 28 +++- client/src/utils/__tests__/svg.test.ts | 110 ++++++++++++++ client/src/utils/icons.ts | 13 ++ client/src/utils/index.ts | 1 + client/src/utils/svg.ts | 134 ++++++++++++++++++ 17 files changed, 494 insertions(+), 44 deletions(-) create mode 100644 client/src/components/ui/CustomIcon.tsx create mode 100644 client/src/components/ui/__tests__/CustomIcon.test.tsx create mode 100644 client/src/hooks/useAdaptiveIcon.ts create mode 100644 client/src/utils/__tests__/svg.test.ts create mode 100644 client/src/utils/svg.ts diff --git a/client/src/components/Chat/Menus/Endpoints/components/GroupIcon.tsx b/client/src/components/Chat/Menus/Endpoints/components/GroupIcon.tsx index 3c4cb840a4..7890979a43 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/GroupIcon.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/GroupIcon.tsx @@ -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 = ({ iconURL, groupName }) => { return (
- ); } diff --git a/client/src/components/MCP/MCPServerMenuItem.tsx b/client/src/components/MCP/MCPServerMenuItem.tsx index 7fcb773bb9..95a3ccdf0f 100644 --- a/client/src/components/MCP/MCPServerMenuItem.tsx +++ b/client/src/components/MCP/MCPServerMenuItem.tsx @@ -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 */}
{server.config?.iconPath ? ( - {displayName} ) : ( diff --git a/client/src/components/MCP/StackedMCPIcons.tsx b/client/src/components/MCP/StackedMCPIcons.tsx index fa04928210..e5a02aad39 100644 --- a/client/src/components/MCP/StackedMCPIcons.tsx +++ b/client/src/components/MCP/StackedMCPIcons.tsx @@ -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 ? ( - {icon.displayName} ) : ( diff --git a/client/src/components/SidePanel/Agents/MCPIcon.tsx b/client/src/components/SidePanel/Agents/MCPIcon.tsx index c6ca84f806..296c142661 100644 --- a/client/src/components/SidePanel/Agents/MCPIcon.tsx +++ b/client/src/components/SidePanel/Agents/MCPIcon.tsx @@ -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(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 ? ( - MCP Icon ) : ( @@ -63,7 +53,7 @@ export default function MCPIcon({ icon, onIconChange }: MCPIconProps) { {localize('com_agents_mcp_icon_size')}
{server.config?.iconPath ? ( - ) : (
diff --git a/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/sections/BasicInfoSection.tsx b/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/sections/BasicInfoSection.tsx index fd78d0bced..c9a92f778d 100644 --- a/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/sections/BasicInfoSection.tsx +++ b/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/sections/BasicInfoSection.tsx @@ -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) => { 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 ( diff --git a/client/src/components/ui/CustomIcon.tsx b/client/src/components/ui/CustomIcon.tsx new file mode 100644 index 0000000000..8e18107b63 --- /dev/null +++ b/client/src/components/ui/CustomIcon.tsx @@ -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; +} + +/** + * 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 ( + + ); + } + + return ( + {alt} + ); +} diff --git a/client/src/components/ui/__tests__/CustomIcon.test.tsx b/client/src/components/ui/__tests__/CustomIcon.test.tsx new file mode 100644 index 0000000000..635321e70c --- /dev/null +++ b/client/src/components/ui/__tests__/CustomIcon.test.tsx @@ -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(); + + 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(); + + const img = container.querySelector('img'); + expect(img).not.toBeNull(); + expect(img).toHaveAttribute('aria-hidden', 'true'); + expect(img).toHaveAttribute('src', '/assets/logo.png'); + }); +}); diff --git a/client/src/components/ui/index.ts b/client/src/components/ui/index.ts index 1412cf4aff..35a62fe19b 100644 --- a/client/src/components/ui/index.ts +++ b/client/src/components/ui/index.ts @@ -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'; diff --git a/client/src/hooks/index.ts b/client/src/hooks/index.ts index 87c62f1728..4be08f2b6f 100644 --- a/client/src/hooks/index.ts +++ b/client/src/hooks/index.ts @@ -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'; diff --git a/client/src/hooks/useAdaptiveIcon.ts b/client/src/hooks/useAdaptiveIcon.ts new file mode 100644 index 0000000000..4b46c4a38c --- /dev/null +++ b/client/src/hooks/useAdaptiveIcon.ts @@ -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(); +/** In-flight resolutions, so concurrent instances of the same icon fetch once. */ +const inFlight = new Map>(); + +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 { + 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 { + 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(() => + 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 }; +} diff --git a/client/src/utils/__tests__/icons.test.ts b/client/src/utils/__tests__/icons.test.ts index 490b968f68..212267b2d7 100644 --- a/client/src/utils/__tests__/icons.test.ts +++ b/client/src/utils/__tests__/icons.test.ts @@ -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); + }); +}); diff --git a/client/src/utils/__tests__/svg.test.ts b/client/src/utils/__tests__/svg.test.ts new file mode 100644 index 0000000000..5cfa80418a --- /dev/null +++ b/client/src/utils/__tests__/svg.test.ts @@ -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 = ''; + expect(isMonochromeSvg(svg)).toBe(true); + }); + + it('treats a black fill as monochrome', () => { + const svg = ''; + expect(isMonochromeSvg(svg)).toBe(true); + }); + + it('treats shorthand black hex as monochrome', () => { + const svg = ''; + expect(isMonochromeSvg(svg)).toBe(true); + }); + + it('treats grayscale shades as monochrome', () => { + const svg = ''; + expect(isMonochromeSvg(svg)).toBe(true); + }); + + it('treats named grayscale colors as monochrome', () => { + const svg = ''; + expect(isMonochromeSvg(svg)).toBe(true); + }); + + it('treats grayscale rgb() as monochrome', () => { + const svg = ''; + expect(isMonochromeSvg(svg)).toBe(true); + }); + + it('treats grayscale hsl() (zero saturation) as monochrome', () => { + const svg = ''; + expect(isMonochromeSvg(svg)).toBe(true); + }); + + it('ignores none/transparent and currentColor', () => { + const svg = + ''; + expect(isMonochromeSvg(svg)).toBe(true); + }); + + it('handles colors defined inside a style block', () => { + const svg = ''; + expect(isMonochromeSvg(svg)).toBe(true); + }); + }); + + describe('multi-color icons (colors preserved)', () => { + it('treats a saturated hex color as multi-color', () => { + const svg = ''; + expect(isMonochromeSvg(svg)).toBe(false); + }); + + it('treats a mix of saturated colors as multi-color', () => { + const svg = + ''; + expect(isMonochromeSvg(svg)).toBe(false); + }); + + it('treats named chromatic colors as multi-color', () => { + const svg = ''; + expect(isMonochromeSvg(svg)).toBe(false); + }); + + it('treats saturated rgb() as multi-color', () => { + const svg = ''; + expect(isMonochromeSvg(svg)).toBe(false); + }); + + it('treats saturated hsl() as multi-color', () => { + const svg = ''; + expect(isMonochromeSvg(svg)).toBe(false); + }); + + it('treats gradient (url reference) fills as multi-color', () => { + const svg = ''; + expect(isMonochromeSvg(svg)).toBe(false); + }); + }); +}); + +describe('sanitizeSvg', () => { + it('strips script tags but keeps drawing elements', () => { + const dirty = ''; + const clean = sanitizeSvg(dirty); + expect(clean).not.toContain(' { + const dirty = ''; + 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 = + ''; + const clean = sanitizeSvg(dirty); + expect(clean.toLowerCase()).not.toContain('foreignobject'); + expect(clean.toLowerCase()).not.toContain('iframe'); + }); +}); diff --git a/client/src/utils/icons.ts b/client/src/utils/icons.ts index a518ba52f3..d28172c461 100644 --- a/client/src/utils/icons.ts +++ b/client/src/utils/icons.ts @@ -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); +} diff --git a/client/src/utils/index.ts b/client/src/utils/index.ts index ea322b047f..7e066ca6e8 100644 --- a/client/src/utils/index.ts +++ b/client/src/utils/index.ts @@ -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'; diff --git a/client/src/utils/svg.ts b/client/src/utils/svg.ts new file mode 100644 index 0000000000..5d5fb4bd64 --- /dev/null +++ b/client/src/utils/svg.ts @@ -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)}`; +}