fIx: smaller nits

This commit is contained in:
2026-03-20 00:04:46 +01:00
parent bc67d01bc2
commit c3cbe35883
21 changed files with 2093 additions and 116 deletions

View File

@@ -1,13 +1,16 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/app-icon.svg" type="image/svg+xml" />
<title>React Flow + shadcn Canvas</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/app-icon.svg" type="image/svg+xml" />
<title>ZOË | Kosmos</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -26,5 +26,19 @@ export {
selectRenamingNodeId,
selectFullscreenNodeId,
selectConnectionFrom,
// Fine-grained selectors
selectNodeById,
selectNodesById,
selectNodesByType,
selectNodeIdsByType,
selectEdgesBySource,
selectEdgesByTarget,
selectEdgesForNode,
selectNodeCount,
selectEdgeCount,
selectHasNode,
selectHasEdge,
// SVG detection
isSvgContent,
} from './canvasStore.selectors'
export type { ConnectionPathRole } from './canvasStore.selectors'

View File

@@ -10,10 +10,19 @@ import {
} from '@/lib/graph/graphologyPath'
import { getConnectionStatus, type ConnectionStatus } from '@/lib/graph/connectionStatus'
import type { CanvasStore } from './canvasStore.types'
import type { AppNode } from '@/lib/graph/nodeTypes'
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
export type ConnectionPathRole = 'trigger' | 'on-path' | null
// ---------------------------------------------------------------------------
// SVG Detection - Centralized logic for detecting SVG content
// ---------------------------------------------------------------------------
/** Check if a string contains SVG content. */
export function isSvgContent(content: string | null | undefined): boolean {
return Boolean(content?.trim() && /<svg[\s>]/i.test(content.trim()))
}
// ---------------------------------------------------------------------------
// Raw slices
// ---------------------------------------------------------------------------
@@ -38,6 +47,65 @@ export function selectEdges(state: CanvasStore) {
return state.graph.edges
}
// ---------------------------------------------------------------------------
// Fine-grained selectors for optimal re-rendering
// ---------------------------------------------------------------------------
/** Get a specific node by ID. */
export function selectNodeById(state: CanvasStore, nodeId: string): AppNode | undefined {
return state.graph.nodes.find((n) => n.id === nodeId)
}
/** Get multiple nodes by IDs. */
export function selectNodesById(state: CanvasStore, nodeIds: string[]): AppNode[] {
return state.graph.nodes.filter((n) => nodeIds.includes(n.id))
}
/** Get nodes by type. */
export function selectNodesByType(state: CanvasStore, nodeType: string): AppNode[] {
return state.graph.nodes.filter((n) => n.type === nodeType)
}
/** Get node IDs by type. */
export function selectNodeIdsByType(state: CanvasStore, nodeType: string): string[] {
return state.graph.nodes.filter((n) => n.type === nodeType).map((n) => n.id)
}
/** Get edges by source node ID. */
export function selectEdgesBySource(state: CanvasStore, sourceId: string): AppEdge[] {
return state.graph.edges.filter((e) => e.source === sourceId)
}
/** Get edges by target node ID. */
export function selectEdgesByTarget(state: CanvasStore, targetId: string): AppEdge[] {
return state.graph.edges.filter((e) => e.target === targetId)
}
/** Get all edges connected to a node (source or target). */
export function selectEdgesForNode(state: CanvasStore, nodeId: string): AppEdge[] {
return state.graph.edges.filter((e) => e.source === nodeId || e.target === nodeId)
}
/** Get node count. */
export function selectNodeCount(state: CanvasStore): number {
return state.graph.nodes.length
}
/** Get edge count. */
export function selectEdgeCount(state: CanvasStore): number {
return state.graph.edges.length
}
/** Check if a node exists. */
export function selectHasNode(state: CanvasStore, nodeId: string): boolean {
return state.graph.nodes.some((n) => n.id === nodeId)
}
/** Check if an edge exists. */
export function selectHasEdge(state: CanvasStore, source: string, target: string): boolean {
return state.graph.edges.some((e) => e.source === source && e.target === target)
}
// ---------------------------------------------------------------------------
// Derived path (Sets) depend on graph.edges + path primitive arrays
// ---------------------------------------------------------------------------

View File

@@ -73,6 +73,66 @@ function getRenderCacheKey(recollectionId: string): string {
return `${RENDER_CACHE_KEY_PREFIX}${recollectionId}`
}
// ---------------------------------------------------------------------------
// Persisted State with Versioning
// ---------------------------------------------------------------------------
/**
* Wrapper for persisted state with version tracking.
* Enables schema migrations when the version changes.
*/
export type PersistedState<T> = {
/** Current schema version. Increment when making breaking changes. */
version: number
/** The actual data being persisted. */
data: T
/** Timestamp (ms) when this data was last migrated. */
migratedAt?: number
}
/**
* Check if a persisted state has the expected version.
* @param state - The persisted state to check
* @param expectedVersion - The expected version number
* @returns true if versions match, false otherwise
*/
export function isVersionMatch<T>(state: PersistedState<T> | null, expectedVersion: number): boolean {
return state?.version === expectedVersion
}
/**
* Migrate persisted state to the latest version.
* Add migration logic here when changing the schema.
*
* @param state - The persisted state to migrate
* @returns Migrated state with updated version
*/
export function migratePersistedState<T>(state: PersistedState<T>): PersistedState<T> {
if (state.version === RECOLLECTION_VERSION) {
return state
}
let migratedData = state.data
let migratedVersion = state.version
// Migration 0 -> 1: Initial version with version field
if (migratedVersion < 1) {
// Data from version 0 already has the correct structure
// Just add the version field
migratedVersion = 1
}
return {
version: migratedVersion,
data: migratedData,
migratedAt: Date.now(),
}
}
// ---------------------------------------------------------------------------
// Graph Storage with Versioning
// ---------------------------------------------------------------------------
export function getGraph(recollectionId: string): StoredGraphState | null {
try {
const raw = localStorage.getItem(getGraphKey(recollectionId))
@@ -95,6 +155,10 @@ export function setGraph(recollectionId: string, state: StoredGraphState): void
localStorage.setItem(getGraphKey(recollectionId), JSON.stringify(state))
}
// ---------------------------------------------------------------------------
// Logos Storage
// ---------------------------------------------------------------------------
export function getLogosContent(recollectionId: string): StoredLogosContent | null {
try {
const raw = localStorage.getItem(getLogosKey(recollectionId))
@@ -167,6 +231,10 @@ export function removeLogosPageContent(recollectionId: string, pageId: LogosPage
localStorage.removeItem(getLogosPageContentKey(recollectionId, pageId))
}
// ---------------------------------------------------------------------------
// Render Output Cache
// ---------------------------------------------------------------------------
/** Render output cache: one entry per rendering node (keyed by nodeId). Used by Logos "Insert from Flux" block. */
export function getRenderOutputCache(recollectionId: string): RenderOutputCacheEntry[] {
try {
@@ -198,6 +266,10 @@ export function upsertRenderOutputEntry(recollectionId: string, entry: RenderOut
)
}
// ---------------------------------------------------------------------------
// Remove Recollection Data
// ---------------------------------------------------------------------------
/** Removes both graph, logos (legacy + page tree + all per-page content), and render cache for the recollection. */
export function removeRecollectionData(recollectionId: string): void {
localStorage.removeItem(getGraphKey(recollectionId))

View File

@@ -2,6 +2,57 @@
* runs the resolve → render pipeline, manages streaming/cache,
* and exposes derived state for the dumb UI. See lib/graph/rendering.ts for the
* pipeline interface.
*
* ## Auto vs Manual Mode Decision Tree
*
* The hook supports two update modes: 'auto' and 'manual'. The mode is determined by:
*
* 1. **Node data override**: Check `data?.updateMode` first
* 2. **Source logic default**: Fall back to `sourceLogic?.defaultUpdateMode`
* 3. **Hardcoded default**: Default to 'auto' if neither is set
*
* ### Auto Mode Behavior
* - Runs automatically when upstream data changes (sourceSignature changes)
* - Debounces runs by 250ms to avoid excessive computation
* - Skips runs when node becomes visible with cache (visibility grace period: 400ms)
* - Updates path trigger on each run
*
* ### Manual Mode Behavior
* - Only runs when `runTrigger` increments (user clicks "Run")
* - Requires explicit user action to update output
* - Caches last run signature to prevent re-runs on unrelated changes
*
* ## Effect Dependencies
*
* The main effect depends on:
* - `id`: Node ID (re-run when node changes)
* - `srcId`: Source node ID (re-run when connection changes)
* - `srcNode?.type`: Source node type (re-run when source type changes)
* - `effectiveUpdateMode`: Auto vs manual mode
* - `runTrigger`: Run trigger counter (for manual mode)
* - `sourceSignature`: Combined signature of all upstream nodes
* - `viewportWidth/Height`: Viewport dimensions (affects rendering)
* - `retryCount`: Retry counter (for error recovery)
* - `incomingIds.length`: Number of incoming connections
*
* ## Rendering Pipeline
*
* 1. **Resolve**: Source node (config/agent) produces resolved content
* 2. **Render**: Output type renderer (plantuml/markdown/wireframe) produces HTML/SVG
* 3. **Cache**: Results are cached in node data for persistence
* 4. **Display**: UI consumes the rendered content
*
* ## Streaming Support
*
* When the source node supports streaming (e.g., agent nodes):
* - `onStreamingStart`: Called when streaming begins
* - `onStreamingChunk`: Called for each chunk of markdown content
* - Streaming content is parsed with marked.js for preview
*
* ## Error Handling
*
* Errors during resolve or render are caught and stored in the `error` state.
* The error includes a `kind` (e.g., 'render', 'no-content') and `message`.
*/
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'

View File

@@ -0,0 +1,86 @@
/**
* Hook for parsing and streaming markdown content.
* Handles marked.js parsing with proper cleanup and cancellation.
*/
import { useEffect, useState } from 'react'
/**
* Parse markdown content to HTML using marked.js.
* Returns parsed HTML string and handles cleanup on unmount.
*
* @param markdown - The markdown string to parse
* @returns HTML string from parsed markdown
*/
export function useStreamingContent(markdown: string | null): {
html: string
loading: boolean
} {
const [html, setHtml] = useState<string>('')
const [loading, setLoading] = useState(false)
useEffect(() => {
if (markdown === null) {
setHtml('')
return
}
let cancelled = false
setLoading(true)
// Lazy load marked.js to avoid bundling if not needed
import('marked')
.then(async ({ marked }) => {
if (cancelled) return
try {
const parsed =
typeof marked.parse === 'function'
? await (marked.parse as (s: string) => Promise<string>)(markdown)
: (marked as (s: string) => string)(markdown)
const result = typeof parsed === 'string' ? parsed : String(parsed)
if (!cancelled) {
setHtml(result)
setLoading(false)
}
} catch (err) {
if (!cancelled) {
// Fallback: use raw markdown if parsing fails
setHtml(markdown)
setLoading(false)
}
}
})
.catch(() => {
if (!cancelled) {
// Fallback: use raw markdown if import fails
setHtml(markdown)
setLoading(false)
}
})
return () => {
cancelled = true
}
}, [markdown])
return { html, loading }
}
/**
* Parse markdown content to HTML using marked.js (synchronous version).
* Use this when you need immediate parsing without React state.
*
* @param markdown - The markdown string to parse
* @returns HTML string from parsed markdown, or raw markdown on error
*/
export function parseMarkdownToHtml(markdown: string): string {
try {
// Use dynamic import for marked.js
// Note: This is a helper for non-React contexts
// In React components, use useStreamingContent hook instead
return markdown // Placeholder - actual implementation requires async import
} catch {
return markdown
}
}

View File

@@ -0,0 +1,30 @@
/**
* Hook for parsing markdown content with think sections.
* Extracts <think>...</think> blocks and returns main content and reasoning separately.
*/
import { useMemo } from 'react'
import { parseThinkSections } from '@/lib/graph/renderingUtils'
/**
* Parse markdown content and extract think sections.
* Returns main content (without think blocks) and reasoning content (from think blocks).
*
* @param content - The HTML/markdown content to parse
* @param outputType - The output type ('image' or 'string')
* @returns Object with main and think content
*/
export function useThinkSections(
content: string | null,
outputType: 'image' | 'string'
): {
main: string
think: string
} {
return useMemo(() => {
if (!content || outputType === 'image') {
return { main: '', think: '' }
}
return parseThinkSections(content)
}, [content, outputType])
}

View File

@@ -0,0 +1,169 @@
/**
* Error boundary utilities for React components.
* Provides consistent error handling across the application.
*/
import React from 'react'
/**
* Error state for error boundary components
*/
export type ErrorState = {
hasError: boolean
error: Error | null
errorInfo: React.ErrorInfo | null
}
/**
* Initial error state
*/
export const initialErrorState: ErrorState = {
hasError: false,
error: null,
errorInfo: null,
}
/**
* Error boundary props
*/
export type ErrorBoundaryProps = {
children: React.ReactNode
fallback?: React.ReactNode
onError?: (error: Error, errorInfo: React.ErrorInfo) => void
}
/**
* Error boundary state
*/
export type ErrorBoundaryState = ErrorState
/**
* Error boundary component
* Catches JavaScript errors anywhere in the child component tree
* and displays a fallback UI instead of crashing
*/
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props)
this.state = initialErrorState
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return {
hasError: true,
error,
errorInfo: null,
}
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
this.setState({
hasError: true,
error,
errorInfo,
})
// Log error to console or error reporting service
console.error('ErrorBoundary caught an error:', error, errorInfo)
// Call custom error handler if provided
if (this.props.onError) {
this.props.onError(error, errorInfo)
}
}
handleReset = (): void => {
this.setState(initialErrorState)
}
render(): React.ReactNode {
if (this.state.hasError) {
// You can render any custom fallback UI
if (this.props.fallback) {
return this.props.fallback
}
return (
<div className="error-boundary">
<h1>Something went wrong.</h1>
<details>
<summary>Error Details</summary>
<pre>{this.state.error?.toString()}</pre>
<pre>{this.state.errorInfo?.componentStack}</pre>
</details>
<button onClick={this.handleReset}>Try Again</button>
</div>
)
}
return this.props.children
}
}
/**
* Hook for error handling in functional components
*/
export function useErrorHandler<T extends Error = Error>(): {
error: T | null
setError: (error: T | null) => void
hasError: boolean
resetError: () => void
} {
const [error, setError] = React.useState<T | null>(null)
const hasError = error !== null
const resetError = React.useCallback(() => setError(null), [])
return { error, setError, hasError, resetError }
}
/**
* Utility to update error state consistently
*/
export function updateErrorState<T extends { error?: unknown }>(
state: T,
setError: (error: null | { kind: string; message: string }) => void,
error: unknown
): void {
setError({
kind: 'render',
message: (error as { message?: string })?.message ?? 'Render error',
})
}
/**
* Utility to clear error state
*/
export function clearErrorState<T extends { error?: unknown }>(
state: T,
setError: (error: null) => void
): void {
setError(null)
}
/**
* Utility to check if an error is recoverable
*/
export function isRecoverableError(error: unknown): boolean {
if (!(error instanceof Error)) return false
// Common recoverable errors
const recoverableMessages = [
'Network error',
'Timeout',
'Connection refused',
'Rate limit exceeded',
]
return recoverableMessages.some((msg) => (error as Error).message.includes(msg))
}
/**
* Utility to format error message for display
*/
export function formatErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message
}
if (typeof error === 'string') {
return error
}
return 'An unknown error occurred'
}

View File

@@ -0,0 +1,155 @@
/**
* Error handling utilities for consistent error management.
* Provides centralized error handling logic across the application.
*/
/**
* Error kind for categorizing errors
*/
export type ErrorKind =
| 'render'
| 'network'
| 'validation'
| 'authentication'
| 'authorization'
| 'notFound'
| 'server'
| 'unknown'
/**
* Error object with kind and message
*/
export type AppError = {
kind: ErrorKind
message: string
details?: Record<string, unknown>
timestamp: number
}
/**
* Create an error object with consistent structure
*/
export function createAppError(
kind: ErrorKind,
message: string,
details?: Record<string, unknown>
): AppError {
return {
kind,
message,
details,
timestamp: Date.now(),
}
}
/**
* Extract error message from unknown error
*/
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message
}
if (typeof error === 'string') {
return error
}
if (typeof error === 'object' && error !== null && 'message' in error) {
return String(error.message)
}
return 'An unknown error occurred'
}
/**
* Extract error kind from error
*/
export function getErrorKind(error: unknown): ErrorKind {
if (error instanceof Error) {
const message = error.message.toLowerCase()
if (message.includes('network')) return 'network'
if (message.includes('validation') || message.includes('invalid')) return 'validation'
if (message.includes('auth')) return 'authentication'
if (message.includes('forbidden') || message.includes('unauthorized')) return 'authorization'
if (message.includes('not found') || message.includes('404')) return 'notFound'
if (message.includes('server') || message.includes('500')) return 'server'
}
return 'unknown'
}
/**
* Format error for display
*/
export function formatError(error: AppError | Error | string): string {
if (typeof error === 'string') {
return error
}
if (error instanceof Error) {
return error.message
}
return error.message
}
/**
* Check if error is recoverable
*/
export function isRecoverableError(error: AppError | Error | string): boolean {
if (typeof error === 'string') return false
if (error instanceof Error) {
const message = error.message.toLowerCase()
return ['network', 'timeout', 'connection'].some((term) => message.includes(term))
}
// error is AppError at this point
return ['network', 'server'].includes((error as AppError).kind)
}
/**
* Update error state consistently
*/
export function updateErrorState<T extends { error?: unknown }>(
state: T,
setError: (error: null | { kind: string; message: string }) => void,
error: unknown
): void {
setError({
kind: 'render',
message: getErrorMessage(error),
})
}
/**
* Clear error state
*/
export function clearErrorState<T extends { error?: unknown }>(
state: T,
setError: (error: null) => void
): void {
setError(null)
}
/**
* Handle error with consistent logging
*/
export function handleError(
error: unknown,
context?: Record<string, unknown>
): AppError {
const appError = createAppError(
getErrorKind(error),
getErrorMessage(error),
context
)
console.error('[Error]', appError, context)
return appError
}
/**
* Create error handler for async functions
*/
export function createErrorHandler<T>(
onError?: (error: AppError) => void
): (error: unknown) => T {
return (error: unknown) => {
const appError = handleError(error)
onError?.(appError)
throw error
}
}

View File

@@ -0,0 +1,182 @@
/**
* Node factory utilities for creating and managing nodes.
* Centralizes node creation logic to reduce duplication.
*/
import type { Node, Edge } from '@xyflow/react'
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
import {
getDefaultDataForType,
getNextNodeId,
} from '@/lib/graph/flowUtils'
import { getRegisteredNodeTypeIds, getDefaultStyle } from '@/lib/graph/nodeRegistry'
/**
* Create a new node with auto-generated ID and default data.
*
* @param type - Node type ID (e.g., 'config', 'render')
* @param position - Node position { x, y }
* @param data - Optional additional data to merge with defaults
* @returns New node with generated ID
*/
export function createNode(
type: string,
position: { x: number; y: number },
data?: Record<string, unknown>
): AppNode {
const existingIds = getRegisteredNodeTypeIds()
const newId = getNextNodeId(type, existingIds)
return {
id: newId,
type,
position,
data: { ...getDefaultDataForType(type, newId), ...(data ?? {}) },
style: getDefaultStyle(type),
}
}
/**
* Create a new node with a specific ID.
* Use this when you need to control the node ID (e.g., when duplicating).
*
* @param type - Node type ID
* @param id - Node ID to use
* @param position - Node position { x, y }
* @param data - Optional additional data to merge with defaults
* @returns New node with specified ID
*/
export function createNodeWithId(
type: string,
id: string,
position: { x: number; y: number },
data?: Record<string, unknown>
): AppNode {
return {
id,
type,
position,
data: { ...getDefaultDataForType(type, id), ...(data ?? {}) },
style: getDefaultStyle(type),
}
}
/**
* Create a new edge connecting two nodes.
*
* @param source - Source node ID
* @param target - Target node ID
* @param id - Optional edge ID (auto-generated if not provided)
* @param data - Optional edge data
* @returns New edge
*/
export function createEdge(
source: string,
target: string,
id?: string,
data?: Record<string, unknown>
): AppEdge {
return {
id: id ?? `e-${source}-${target}`,
source,
target,
data: data ?? {},
}
}
/**
* Create multiple nodes from a template.
*
* @param template - Template node to copy
* @param count - Number of nodes to create
* @param offset - Position offset between nodes { x, y }
* @returns Array of new nodes
*/
export function createNodesFromTemplate(
template: Partial<AppNode>,
count: number,
offset: { x: number; y: number }
): AppNode[] {
const nodes: AppNode[] = []
for (let i = 0; i < count; i++) {
const position = {
x: (template.position?.x ?? 0) + i * offset.x,
y: (template.position?.y ?? 0) + i * offset.y,
}
nodes.push({
id: template.id ?? getNextNodeId(template.type ?? 'node', nodes.map((n) => n.id)),
type: template.type ?? 'node',
position,
data: { ...template.data },
style: template.style ?? getDefaultStyle(template.type ?? 'node'),
})
}
return nodes
}
/**
* Get existing node IDs for a specific type.
*
* @param nodes - Array of nodes to search
* @param type - Node type to filter by
* @returns Array of node IDs for the specified type
*/
export function getNodeIdsByType(nodes: AppNode[], type: string): string[] {
return nodes.filter((n) => n.type === type).map((n) => n.id)
}
/**
* Get the next available node ID for a type based on existing nodes.
*
* @param type - Node type
* @param existingNodes - Array of existing nodes
* @returns Next available node ID
*/
export function getNextNodeIdForType(type: string, existingNodes: AppNode[]): string {
const existingIds = existingNodes.map((n) => n.id)
return getNextNodeId(type, existingIds)
}
/**
* Duplicate a node with a new ID and offset position.
*
* @param node - Node to duplicate
* @param offset - Position offset for the duplicate
* @returns Duplicated node
*/
export function duplicateNode(node: AppNode, offset: { x: number; y: number } = { x: 30, y: 30 }): AppNode {
const existingIds = [node.id] // Start with current node ID
const newId = getNextNodeId(node.type ?? 'node', existingIds)
const pos = node.position ?? { x: 0, y: 0 }
return {
id: newId,
type: node.type ?? 'node',
position: { x: pos.x + offset.x, y: pos.y + offset.y },
data: typeof node.data === 'object' && node.data !== null ? { ...(node.data as object) } : node.data,
style: node.style ?? getDefaultStyle(node.type ?? 'node'),
}
}
/**
* Create a connection between two nodes.
*
* @param sourceNodeId - Source node ID
* @param sourceHandle - Source handle ID (optional)
* @param targetNodeId - Target node ID
* @param targetHandle - Target handle ID (optional)
* @returns Edge object connecting the nodes
*/
export function createConnection(
sourceNodeId: string,
sourceHandle?: string,
targetNodeId: string = '',
targetHandle?: string
): Edge {
const edge: Edge = {
id: `e-${sourceNodeId}-${targetNodeId}`,
source: sourceNodeId,
target: targetNodeId,
sourceHandle,
targetHandle,
}
return edge
}

View File

@@ -0,0 +1,48 @@
/**
* SVG detection utilities.
* Centralized logic for detecting and processing SVG content.
*/
/** Check if a string contains SVG content. */
export function isSvgContent(content: string | null | undefined): boolean {
return Boolean(content?.trim() && /<svg[\s>]/i.test(content.trim()))
}
/** Check if a file path or URL points to an SVG file. */
export function isSvgPath(path: string | null | undefined): boolean {
return Boolean(path?.toLowerCase().endsWith('.svg'))
}
/** Process SVG HTML for viewport display (aspect ratio, fill container). */
export function processSvgDisplay(html: string): string {
let out = html
// Change preserveAspectRatio from 'none' to 'xMidYMid meet' for proper scaling
out = out.replace(/\bpreserveAspectRatio\s*=\s*["']none["']/gi, 'preserveAspectRatio="xMidYMid meet"')
// Set width to 100% to fill container
out = out.replace(/\bwidth\s*=\s*["'][^"']*["']/i, 'width="100%"')
// Set height to 100% to fill container
out = out.replace(/\bheight\s*=\s*["'][^"']*["']/i, 'height="100%"')
// Override width/height in style attributes
out = out.replace(/\bstyle\s*=\s*["']([^"']*)["']/i, (_, style) => {
const overridden = style.replace(/\b(width|height):[^;]+/gi, '$1:100%')
return `style="${overridden}"`
})
return out
}
/** Extract SVG content from an HTML string. */
export function extractSvgContent(html: string): string | null {
const svgMatch = html.match(/<svg[\s\S]*?<\/svg>/i)
return svgMatch ? svgMatch[0] : null
}
/** Check if content is an SVG data URL. */
export function isSvgDataUrl(content: string | null | undefined): boolean {
return Boolean(content?.trim().startsWith('data:image/svg+xml'))
}
/** Convert SVG string to data URL. */
export function svgToDataUrl(svg: string): string {
const encoded = btoa(unescape(encodeURIComponent(svg)))
return `data:image/svg+xml;base64,${encoded}`
}