import { isRecord } from './records' export type PluginIconFormat = 'path' | 'data-url' | 'asset-url' | 'structured' | 'fallback' export interface StructuredPluginIconStop { offset: string color: string opacity?: number } export interface StructuredPluginIconBackground { fill: string radius: number } export interface StructuredPluginIconPath { d: string fill?: string stroke?: string strokeWidth?: number opacity?: number } export interface StructuredPluginIcon { type: 'mint-icon' viewBox: string background?: StructuredPluginIconBackground gradient?: { type: 'linear' angle: number stops: StructuredPluginIconStop[] } paths: StructuredPluginIconPath[] } export interface DetectedPluginIcon { format: PluginIconFormat value: string | StructuredPluginIcon } // MINT canonical plugin placeholder — page-with-corner-fold glyph. export const PLUGIN_ICON_FALLBACK_PATH = 'M14 7v4a1 1 0 0 0 1 1h4M5 3h9l5 5v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z' const PATH_REGEX = /^[Mm][\s,\d\-.]/ const RASTER_DATA_URL_REGEX = /^data:image\/(png|jpeg|jpg|gif|webp);/ const SAME_ORIGIN_ASSET_URL_REGEX = /^\/(?!\/)[^\s?#]+\.(?:svg|png|jpe?g|gif|webp)(?:[?#].*)?$/i const VIEW_BOX_REGEX = /^-?\d+(?:\.\d+)?\s+-?\d+(?:\.\d+)?\s+\d+(?:\.\d+)?\s+\d+(?:\.\d+)?$/ const COLOR_REGEX = /^(#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})|currentColor|white|black|none)$/i const MAX_STRUCTURED_ICON_LENGTH = 8000 const MAX_PATHS = 8 const MAX_STOPS = 8 export function normalizePluginIconSource(icon?: string): string { return (icon ?? '').replace(/^[\s]+/, '') } export function detectPluginIcon(icon?: string): DetectedPluginIcon { const raw = normalizePluginIconSource(icon) if (!raw) return { format: 'fallback', value: PLUGIN_ICON_FALLBACK_PATH } const structured = parseStructuredPluginIcon(raw) if (structured) return { format: 'structured', value: structured } if (PATH_REGEX.test(raw)) return { format: 'path', value: raw } if (RASTER_DATA_URL_REGEX.test(raw)) return { format: 'data-url', value: raw } if (raw.startsWith('https://') || SAME_ORIGIN_ASSET_URL_REGEX.test(raw)) { return { format: 'asset-url', value: raw } } return { format: 'fallback', value: PLUGIN_ICON_FALLBACK_PATH } } export function isPluginIconFormat(icon?: string): boolean { const raw = normalizePluginIconSource(icon) return !!raw && detectPluginIcon(raw).format !== 'fallback' } function parseStructuredPluginIcon(raw: string): StructuredPluginIcon | undefined { if (!raw.startsWith('{') || raw.length > MAX_STRUCTURED_ICON_LENGTH) return undefined let parsed: unknown try { parsed = JSON.parse(raw) } catch { return undefined } if (!isRecord(parsed) || parsed.type !== 'mint-icon') return undefined const viewBox = typeof parsed.viewBox === 'string' && VIEW_BOX_REGEX.test(parsed.viewBox) ? parsed.viewBox : '0 0 24 24' const paths = parseStructuredPaths(parsed.paths) if (!paths.length) return undefined const gradient = parseStructuredGradient(parsed.gradient) const background = parseStructuredBackground(parsed.background, gradient) return { type: 'mint-icon', viewBox, ...(background ? { background } : {}), ...(gradient ? { gradient } : {}), paths, } } function parseStructuredPaths(value: unknown): StructuredPluginIconPath[] { if (!Array.isArray(value)) return [] return value.slice(0, MAX_PATHS).flatMap((item) => { if (!isRecord(item) || typeof item.d !== 'string' || !item.d.trim()) return [] const path: StructuredPluginIconPath = { d: item.d.slice(0, 2000) } if (isSafeColor(item.fill)) path.fill = item.fill if (isSafeColor(item.stroke)) path.stroke = item.stroke if (isFiniteNumber(item.strokeWidth, 0, 24)) path.strokeWidth = item.strokeWidth if (isFiniteNumber(item.opacity, 0, 1)) path.opacity = item.opacity return [path] }) } function parseStructuredGradient(value: unknown): StructuredPluginIcon['gradient'] | undefined { if (!isRecord(value) || value.type !== 'linear') return undefined const stops = parseStructuredStops(value.stops) if (stops.length < 2 && isSafeColor(value.from) && isSafeColor(value.to)) { stops.push( { offset: '0%', color: value.from }, { offset: '100%', color: value.to }, ) } if (stops.length < 2) return undefined return { type: 'linear', angle: isFiniteNumber(value.angle, 0, 360) ? value.angle : 135, stops, } } function parseStructuredStops(value: unknown): StructuredPluginIconStop[] { if (!Array.isArray(value)) return [] return value.slice(0, MAX_STOPS).flatMap((item) => { if (!isRecord(item) || !isSafeColor(item.color)) return [] const offset = parseOffset(item.offset) if (!offset) return [] return [{ offset, color: item.color, ...(isFiniteNumber(item.opacity, 0, 1) ? { opacity: item.opacity } : {}), }] }) } function parseStructuredBackground( value: unknown, gradient: StructuredPluginIcon['gradient'] | undefined, ): StructuredPluginIconBackground | undefined { if (!isRecord(value)) return undefined const fill = value.fill === 'gradient' && gradient ? 'gradient' : isSafeColor(value.fill) ? value.fill : undefined if (!fill) return undefined return { fill, radius: isFiniteNumber(value.radius, 0, 12) ? value.radius : 5, } } function parseOffset(value: unknown): string | undefined { if (isFiniteNumber(value, 0, 1)) return String(value) if (typeof value !== 'string') return undefined if (/^(?:100|(?:[1-9]?\d)(?:\.\d+)?)%$/.test(value)) return value return undefined } function isSafeColor(value: unknown): value is string { return typeof value === 'string' && COLOR_REGEX.test(value) } function isFiniteNumber(value: unknown, min: number, max: number): value is number { return typeof value === 'number' && Number.isFinite(value) && value >= min && value <= max }