From 08f47f8688dd17c1496f10c70c9cb57321c40838 Mon Sep 17 00:00:00 2001 From: dimakis Date: Thu, 2 Jul 2026 23:11:34 +0100 Subject: [PATCH 1/8] feat(frontend): add mermaid diagram rendering and fix preview card auto-close Add mermaid.js support to render diagrams inline instead of showing raw code. Mermaid code blocks are detected in the pre component handler and rendered via a new MermaidBlock component with dark theme styling. Fix MarkdownPreviewCard auto-close bug by memoizing the ReactMarkdown components object in TextBubble. Previously, every parent re-render recreated the components with new function references, causing react-markdown to unmount and remount the tree, resetting expanded state. Co-Authored-By: Claude Opus 4.6 --- frontend/package.json | 1 + frontend/src/components/MermaidBlock.tsx | 85 ++ frontend/src/components/MessageBubble.tsx | 140 +- .../__tests__/MessageBubble.test.ts | 20 + frontend/src/lib/markdown-config.tsx | 14 + frontend/src/styles/global.css | 23 + package-lock.json | 1343 +++++++++++++++-- 7 files changed, 1424 insertions(+), 202 deletions(-) create mode 100644 frontend/src/components/MermaidBlock.tsx diff --git a/frontend/package.json b/frontend/package.json index 68304bf0..769bccd9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,7 @@ "@xterm/addon-web-links": "^0.11.0", "@xterm/xterm": "^5.5.0", "highlight.js": "^11.11.1", + "mermaid": "^11.16.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-markdown": "^10.1.0", diff --git a/frontend/src/components/MermaidBlock.tsx b/frontend/src/components/MermaidBlock.tsx new file mode 100644 index 00000000..2e05ebe0 --- /dev/null +++ b/frontend/src/components/MermaidBlock.tsx @@ -0,0 +1,85 @@ +import { useEffect, useRef, useState } from 'react'; +import mermaid from 'mermaid'; +import { CopyButton } from './CopyButton'; + +mermaid.initialize({ + startOnLoad: false, + theme: 'dark', + themeVariables: { + darkMode: true, + background: '#1e1e2e', + primaryColor: '#7c3aed', + primaryTextColor: '#e2e8f0', + primaryBorderColor: '#6366f1', + lineColor: '#94a3b8', + secondaryColor: '#374151', + tertiaryColor: '#1f2937', + noteBkgColor: '#374151', + noteTextColor: '#e2e8f0', + fontFamily: 'inherit', + }, +}); + +let renderCounter = 0; + +interface MermaidBlockProps { + code: string; +} + +export function MermaidBlock({ code }: MermaidBlockProps) { + const containerRef = useRef(null); + const [error, setError] = useState(null); + const [svg, setSvg] = useState(null); + + useEffect(() => { + let cancelled = false; + const id = `mermaid-${++renderCounter}`; + + async function render() { + try { + const { svg: rendered } = await mermaid.render(id, code); + if (!cancelled) { + setSvg(rendered); + setError(null); + } + } catch { + if (!cancelled) { + setError('Invalid diagram'); + setSvg(null); + } + // Clean up any leftover element mermaid may have created + document.getElementById(`d${id}`)?.remove(); + } + } + + render(); + return () => { + cancelled = true; + }; + }, [code]); + + if (error) { + // Fall back to plain code block + return ( +
+
+          {code}
+        
+ +
+ ); + } + + if (!svg) return null; + + return ( +
+
+ +
+ ); +} diff --git a/frontend/src/components/MessageBubble.tsx b/frontend/src/components/MessageBubble.tsx index f941b112..079be085 100644 --- a/frontend/src/components/MessageBubble.tsx +++ b/frontend/src/components/MessageBubble.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useMemo } from 'react'; import ReactMarkdown, { defaultUrlTransform } from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeHighlight from 'rehype-highlight'; @@ -11,6 +11,7 @@ import { ShareButton } from './ShareButton'; import { ReadAloudButton } from './ReadAloudButton'; import { extractText } from '../lib/extractText'; import { MarkdownPreviewCard } from './MarkdownPreviewCard'; +import { MermaidBlock } from './MermaidBlock'; const COLLAPSE_HEIGHT = 300; @@ -101,6 +102,79 @@ export function TextBubble({ content, streaming = false, timestamp, readAloud }: const showCollapsed = isLong && collapsed && !streaming; + // Memoize components so react-markdown preserves component instances + // (e.g. MarkdownPreviewCard expanded state) across parent re-renders. + const mdComponents = useMemo( + () => ({ + table: ({ children, ...props }: React.ComponentProps<'table'>) => ( +
+ {children}
+
+ ), + pre: ({ children, ...props }: React.ComponentProps<'pre'>) => { + // Detect mermaid code blocks and render as diagrams + const child = React.Children.toArray(children)[0]; + if (React.isValidElement(child)) { + const className = (child.props as Record)?.className; + if (typeof className === 'string' && /language-mermaid/.test(className)) { + const text = extractText(children); + return ; + } + } + const text = extractText(children); + return ( +
+
{children}
+ +
+ ); + }, + p: ({ children }: React.ComponentProps<'p'>) => { + const childArray = React.Children.toArray(children); + if (childArray.length === 1 && React.isValidElement(childArray[0])) { + const el = childArray[0] as React.ReactElement>; + const href = el.props?.href as string | undefined; + if (href?.startsWith(FILE_SCHEME)) { + const filePath = decodeURIComponent(href.slice(FILE_SCHEME.length)); + if (/\.mdx?$/i.test(filePath)) { + return ; + } + } + } + return

{children}

; + }, + a: ({ href, children }: React.ComponentProps<'a'>) => { + if (href?.startsWith(FILE_SCHEME)) { + const filePath = decodeURIComponent(href.slice(FILE_SCHEME.length)); + return ( + + { + e.preventDefault(); + navigate( + `/files?path=${encodeURIComponent(filePath)}&from=${encodeURIComponent(currentPath)}`, + ); + }} + > + {children} + + + + ); + } + return ( + + {children} + + ); + }, + }), + [navigate, currentPath], + ); + return (
(url.startsWith(FILE_SCHEME) ? url : defaultUrlTransform(url))} - components={{ - table: ({ children, ...props }) => ( -
- {children}
-
- ), - pre: ({ children, ...props }) => { - const text = extractText(children); - return ( -
-
{children}
- -
- ); - }, - // When a paragraph contains a single file-path link to a .md/.mdx - // file, promote it to an inline preview card instead of a plain link. - // In ReactMarkdown v10, children are unrendered component instances — - // the `a` handler hasn't run yet — so we check `href` (the prop - // ReactMarkdown passes) rather than rendered DOM attributes. - p: ({ children }) => { - const childArray = React.Children.toArray(children); - if (childArray.length === 1 && React.isValidElement(childArray[0])) { - const el = childArray[0] as React.ReactElement>; - const href = el.props?.href as string | undefined; - if (href?.startsWith(FILE_SCHEME)) { - const filePath = decodeURIComponent(href.slice(FILE_SCHEME.length)); - if (/\.mdx?$/i.test(filePath)) { - return ; - } - } - } - return

{children}

; - }, - a: ({ href, children }) => { - if (href?.startsWith(FILE_SCHEME)) { - const filePath = decodeURIComponent(href.slice(FILE_SCHEME.length)); - return ( - - { - e.preventDefault(); - navigate( - `/files?path=${encodeURIComponent(filePath)}&from=${encodeURIComponent(currentPath)}`, - ); - }} - > - {children} - - - - ); - } - return ( - - {children} - - ); - }, - }} + components={mdComponents} > {processed} diff --git a/frontend/src/components/__tests__/MessageBubble.test.ts b/frontend/src/components/__tests__/MessageBubble.test.ts index 24b4058b..cb53d6fc 100644 --- a/frontend/src/components/__tests__/MessageBubble.test.ts +++ b/frontend/src/components/__tests__/MessageBubble.test.ts @@ -226,6 +226,26 @@ describe('TextBubble code block CopyButton', () => { }); }); +describe('TextBubble mermaid code blocks', () => { + it('renders MermaidBlock for language-mermaid code blocks', () => { + renderToStaticMarkup(createElement(TextBubble, { content: 'test' })); + const pre = capturedComponents!.pre; + const codeEl = createElement('code', { className: 'language-mermaid' }, 'graph TD; A-->B;'); + const result = pre({ children: codeEl }); + // Should render MermaidBlock, not code-block-wrapper + expect(result.type).not.toBe('div'); + expect(result.props.code).toBe('graph TD; A-->B;'); + }); + + it('renders normal code block for non-mermaid languages', () => { + renderToStaticMarkup(createElement(TextBubble, { content: 'test' })); + const pre = capturedComponents!.pre; + const codeEl = createElement('code', { className: 'language-python' }, 'print("hi")'); + const html = renderToStaticMarkup(pre({ children: codeEl })); + expect(html).toContain('code-block-wrapper'); + }); +}); + describe('TextBubble markdown preview card promotion', () => { it('provides a custom p component to ReactMarkdown', () => { renderToStaticMarkup(createElement(TextBubble, { content: 'test' })); diff --git a/frontend/src/lib/markdown-config.tsx b/frontend/src/lib/markdown-config.tsx index e2d01bf6..0ac6960e 100644 --- a/frontend/src/lib/markdown-config.tsx +++ b/frontend/src/lib/markdown-config.tsx @@ -4,12 +4,15 @@ import rehypeRaw from 'rehype-raw'; import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; import type { Components } from 'react-markdown'; import type { PluggableList } from 'unified'; +import { MermaidBlock } from '../components/MermaidBlock'; +import { extractText } from './extractText'; const sanitizeSchema = { ...defaultSchema, attributes: { ...defaultSchema.attributes, img: [...(defaultSchema.attributes?.img ?? []), 'width', 'height'], + code: [...(defaultSchema.attributes?.code ?? []), 'className'], }, }; @@ -22,4 +25,15 @@ export const markdownComponents: Components = { {children}
), + pre: ({ children, ...props }) => { + const child = React.Children.toArray(children)[0]; + if (React.isValidElement(child)) { + const className = (child.props as Record)?.className; + if (typeof className === 'string' && /language-mermaid/.test(className)) { + const text = extractText(children); + return ; + } + } + return
{children}
; + }, }; diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 9fd01649..3690db9b 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -2123,6 +2123,29 @@ textarea:focus { } } +/* ===== Mermaid Diagrams ===== */ + +.mermaid-block { + position: relative; + margin: 0.5em 0; + border-radius: 6px; + background: var(--code-bg); + border: 1px solid var(--border); + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.mermaid-block-svg { + padding: 0.75rem; + display: flex; + justify-content: center; +} + +.mermaid-block-svg svg { + max-width: 100%; + height: auto; +} + /* ===== Collapsible Messages ===== */ .msg-bubble--collapsed .msg-bubble-markdown { diff --git a/package-lock.json b/package-lock.json index 46b186ea..b9a1bddf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -88,6 +88,7 @@ "@xterm/addon-web-links": "^0.11.0", "@xterm/xterm": "^5.5.0", "highlight.js": "^11.11.1", + "mermaid": "^11.16.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-markdown": "^10.1.0", @@ -119,6 +120,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/@anthropic-ai/claude-agent-sdk": { "version": "0.2.109", "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.109.tgz", @@ -556,6 +570,12 @@ "node": ">=6.9.0" } }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "license": "MIT" + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -694,6 +714,12 @@ "@capacitor/core": ">=8.0.0" } }, + "node_modules/@chevrotain/types": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", + "license": "Apache-2.0" + }, "node_modules/@commitlint/cli": { "version": "20.5.0", "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.5.0.tgz", @@ -1809,6 +1835,23 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.3.tgz", + "integrity": "sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, "node_modules/@img/sharp-darwin-arm64": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", @@ -2422,6 +2465,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mermaid-js/parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.0.tgz", + "integrity": "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==", + "license": "MIT", + "dependencies": { + "@chevrotain/types": "~11.1.2" + } + }, "node_modules/@mitzo/client": { "resolved": "packages/client", "link": true @@ -3709,6 +3761,259 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.4.tgz", + "integrity": "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -3775,6 +4080,12 @@ "@types/node": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -3930,6 +4241,13 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -4234,6 +4552,16 @@ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "license": "ISC" }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -5285,187 +5613,716 @@ "node": ">=18" } }, - "node_modules/conventional-changelog-conventionalcommits": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-9.3.1.tgz", - "integrity": "sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw==", - "dev": true, + "node_modules/conventional-changelog-conventionalcommits": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-9.3.1.tgz", + "integrity": "sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw==", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/conventional-commits-parser": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.4.0.tgz", + "integrity": "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/stream-utils": "^1.2.0", + "meow": "^13.0.0" + }, + "bin": { + "conventional-commits-parser": "dist/cli/index.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, + "node_modules/cosmiconfig": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", + "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cosmiconfig-typescript-loader": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.3.0.tgz", + "integrity": "sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jiti": "2.6.1" + }, + "engines": { + "node": ">=v18" + }, + "peerDependencies": { + "@types/node": "*", + "cosmiconfig": ">=9", + "typescript": ">=5" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/cytoscape": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.0.tgz", + "integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-dsv/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", "license": "ISC", "dependencies": { - "compare-func": "^2.0.0" + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" }, "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/conventional-commits-parser": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.4.0.tgz", - "integrity": "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==", - "dev": true, - "license": "MIT", + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", "dependencies": { - "@simple-libs/stream-utils": "^1.2.0", - "meow": "^13.0.0" - }, - "bin": { - "conventional-commits-parser": "dist/cli/index.js" + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" }, "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", "engines": { - "node": ">= 0.6" + "node": ">=12" } }, - "node_modules/cookie-parser": { - "version": "1.4.7", - "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", - "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", - "license": "MIT", + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", "dependencies": { - "cookie": "0.7.2", - "cookie-signature": "1.0.6" + "d3-path": "^3.1.0" }, "engines": { - "node": ">= 0.8.0" + "node": ">=12" } }, - "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", - "license": "MIT" - }, - "node_modules/cookiejar": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", - "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", - "dev": true, - "license": "MIT" - }, - "node_modules/core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", - "license": "MIT" - }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "license": "MIT", + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", "dependencies": { - "object-assign": "^4", - "vary": "^1" + "d3-array": "2 - 3" }, "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">=12" } }, - "node_modules/cosmiconfig": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", - "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", - "dev": true, - "license": "MIT", + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", "dependencies": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" + "d3-time": "1 - 3" }, "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "node": ">=12" } }, - "node_modules/cosmiconfig-typescript-loader": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.3.0.tgz", - "integrity": "sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==", - "dev": true, - "license": "MIT", + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", "dependencies": { - "jiti": "2.6.1" + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" }, "engines": { - "node": ">=v18" + "node": ">=12" }, "peerDependencies": { - "@types/node": "*", - "cosmiconfig": ">=9", - "typescript": ">=5" + "d3-selection": "2 - 3" } }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" }, "engines": { - "node": ">= 8" + "node": ">=12" } }, - "node_modules/css-tree": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "dev": true, + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", "license": "MIT", "dependencies": { - "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + "d3": "^7.9.0", + "lodash-es": "^4.17.21" } }, - "node_modules/css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", - "dev": true, - "license": "MIT" - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" - }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -5500,6 +6357,12 @@ "node": "*" } }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -5578,6 +6441,15 @@ "node": ">=8" } }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -5657,6 +6529,15 @@ "license": "MIT", "peer": true }, + "node_modules/dompurify": { + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dot-prop": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", @@ -5854,6 +6735,16 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", + "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -6902,6 +7793,12 @@ "node": ">=14.0.0" } }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -7362,7 +8259,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", - "dev": true, "license": "MIT", "funding": { "type": "github", @@ -7411,6 +8307,15 @@ "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", "license": "MIT" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -7853,6 +8758,31 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7863,6 +8793,11 @@ "json-buffer": "3.0.1" } }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -7873,6 +8808,12 @@ "node": ">=6" } }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -7959,6 +8900,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", @@ -8172,6 +9119,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -8501,6 +9460,48 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mermaid": { + "version": "11.16.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.0.tgz", + "integrity": "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.2", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.2.0", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.33.3", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.20", + "dompurify": "^3.3.3", + "es-toolkit": "^1.45.1", + "katex": "^0.16.45", + "khroma": "^2.1.0", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" + } + }, + "node_modules/mermaid/node_modules/uuid": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -9516,6 +10517,12 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/package-manager-detector": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.7.0.tgz", + "integrity": "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==", + "license": "MIT" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -9595,6 +10602,12 @@ "node": ">= 0.8" } }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -9799,6 +10812,22 @@ "node": ">=10.4.0" } }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, "node_modules/postcss": { "version": "8.5.10", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", @@ -10494,6 +11523,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, "node_modules/rollup": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", @@ -10539,6 +11574,18 @@ "fsevents": "~2.3.2" } }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -10565,6 +11612,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -11087,6 +12140,12 @@ "inline-style-parser": "0.2.7" } }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, "node_modules/superagent": { "version": "10.3.0", "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", @@ -11264,7 +12323,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -11413,6 +12471,15 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-dedent": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz", + "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", From 01e46b8b1b3cfdc5226b3e1d2081c75cb191de72 Mon Sep 17 00:00:00 2001 From: dimakis Date: Thu, 2 Jul 2026 23:27:37 +0100 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20address=20Centaur=20review=20?= =?UTF-8?q?=E2=80=94=20useId,=20deferred=20init,=20sanitizer,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace module-scoped renderCounter with React useId() for stable, concurrent-safe mermaid element IDs - Defer mermaid.initialize() to first render instead of module import - Restrict rehype-sanitize className allowlist to language-* pattern - Add markdown-config pre component test coverage Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/MermaidBlock.tsx | 47 +++++++++++-------- .../src/lib/__tests__/markdown-config.test.ts | 29 ++++++++++++ frontend/src/lib/markdown-config.tsx | 3 +- 3 files changed, 58 insertions(+), 21 deletions(-) create mode 100644 frontend/src/lib/__tests__/markdown-config.test.ts diff --git a/frontend/src/components/MermaidBlock.tsx b/frontend/src/components/MermaidBlock.tsx index 2e05ebe0..fc38da0d 100644 --- a/frontend/src/components/MermaidBlock.tsx +++ b/frontend/src/components/MermaidBlock.tsx @@ -1,39 +1,46 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useId, useRef, useState } from 'react'; import mermaid from 'mermaid'; import { CopyButton } from './CopyButton'; -mermaid.initialize({ - startOnLoad: false, - theme: 'dark', - themeVariables: { - darkMode: true, - background: '#1e1e2e', - primaryColor: '#7c3aed', - primaryTextColor: '#e2e8f0', - primaryBorderColor: '#6366f1', - lineColor: '#94a3b8', - secondaryColor: '#374151', - tertiaryColor: '#1f2937', - noteBkgColor: '#374151', - noteTextColor: '#e2e8f0', - fontFamily: 'inherit', - }, -}); +let mermaidInitialized = false; -let renderCounter = 0; +function ensureMermaidInit() { + if (mermaidInitialized) return; + mermaidInitialized = true; + mermaid.initialize({ + startOnLoad: false, + theme: 'dark', + themeVariables: { + darkMode: true, + background: '#1e1e2e', + primaryColor: '#7c3aed', + primaryTextColor: '#e2e8f0', + primaryBorderColor: '#6366f1', + lineColor: '#94a3b8', + secondaryColor: '#374151', + tertiaryColor: '#1f2937', + noteBkgColor: '#374151', + noteTextColor: '#e2e8f0', + fontFamily: 'inherit', + }, + }); +} interface MermaidBlockProps { code: string; } export function MermaidBlock({ code }: MermaidBlockProps) { + const instanceId = useId(); const containerRef = useRef(null); const [error, setError] = useState(null); const [svg, setSvg] = useState(null); useEffect(() => { + ensureMermaidInit(); let cancelled = false; - const id = `mermaid-${++renderCounter}`; + // useId() returns a stable, unique string per component instance + const id = `mermaid-${instanceId.replace(/:/g, '')}`; async function render() { try { diff --git a/frontend/src/lib/__tests__/markdown-config.test.ts b/frontend/src/lib/__tests__/markdown-config.test.ts new file mode 100644 index 00000000..be38fc28 --- /dev/null +++ b/frontend/src/lib/__tests__/markdown-config.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { createElement } from 'react'; +import { markdownComponents } from '../markdown-config'; + +describe('markdownComponents.pre', () => { + const pre = markdownComponents.pre!; + + it('renders MermaidBlock for language-mermaid code blocks', () => { + const codeEl = createElement('code', { className: 'language-mermaid' }, 'graph TD; A-->B;'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = (pre as any)({ children: codeEl }); + expect(result.type).not.toBe('pre'); + expect(result.props.code).toBe('graph TD; A-->B;'); + }); + + it('renders normal pre for non-mermaid code blocks', () => { + const codeEl = createElement('code', { className: 'language-python' }, 'print("hi")'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = (pre as any)({ children: codeEl }); + expect(result.type).toBe('pre'); + }); + + it('renders normal pre when code has no className', () => { + const codeEl = createElement('code', null, 'plain text'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = (pre as any)({ children: codeEl }); + expect(result.type).toBe('pre'); + }); +}); diff --git a/frontend/src/lib/markdown-config.tsx b/frontend/src/lib/markdown-config.tsx index 0ac6960e..b5542f0f 100644 --- a/frontend/src/lib/markdown-config.tsx +++ b/frontend/src/lib/markdown-config.tsx @@ -12,7 +12,8 @@ const sanitizeSchema = { attributes: { ...defaultSchema.attributes, img: [...(defaultSchema.attributes?.img ?? []), 'width', 'height'], - code: [...(defaultSchema.attributes?.code ?? []), 'className'], + // Only allow language-* classes (set by rehype-highlight) — not arbitrary classNames + code: [...(defaultSchema.attributes?.code ?? []), ['className', /^language-/]], }, }; From f4aebb4d0169afcd3497fa695e1088441f437b92 Mon Sep 17 00:00:00 2001 From: dimakis Date: Thu, 2 Jul 2026 23:52:22 +0100 Subject: [PATCH 3/8] =?UTF-8?q?fix:=20address=20second=20Centaur=20review?= =?UTF-8?q?=20=E2=80=94=20security,=20dedup,=20dead=20code,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add securityLevel: 'strict' to mermaid.initialize() - Remove dead containerRef - Add instanceId to useEffect dependency array - Extract shared getMermaidCode() helper to deduplicate detection logic between MessageBubble and markdown-config - Add MermaidBlock component tests Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/MermaidBlock.tsx | 15 ++---- frontend/src/components/MessageBubble.tsx | 12 ++--- .../components/__tests__/MermaidBlock.test.ts | 51 +++++++++++++++++++ frontend/src/lib/markdown-config.tsx | 13 ++--- frontend/src/lib/mermaid-detect.ts | 16 ++++++ 5 files changed, 77 insertions(+), 30 deletions(-) create mode 100644 frontend/src/components/__tests__/MermaidBlock.test.ts create mode 100644 frontend/src/lib/mermaid-detect.ts diff --git a/frontend/src/components/MermaidBlock.tsx b/frontend/src/components/MermaidBlock.tsx index fc38da0d..bc9e20a7 100644 --- a/frontend/src/components/MermaidBlock.tsx +++ b/frontend/src/components/MermaidBlock.tsx @@ -1,4 +1,4 @@ -import { useEffect, useId, useRef, useState } from 'react'; +import { useEffect, useId, useState } from 'react'; import mermaid from 'mermaid'; import { CopyButton } from './CopyButton'; @@ -9,6 +9,7 @@ function ensureMermaidInit() { mermaidInitialized = true; mermaid.initialize({ startOnLoad: false, + securityLevel: 'strict', theme: 'dark', themeVariables: { darkMode: true, @@ -32,14 +33,12 @@ interface MermaidBlockProps { export function MermaidBlock({ code }: MermaidBlockProps) { const instanceId = useId(); - const containerRef = useRef(null); const [error, setError] = useState(null); const [svg, setSvg] = useState(null); useEffect(() => { ensureMermaidInit(); let cancelled = false; - // useId() returns a stable, unique string per component instance const id = `mermaid-${instanceId.replace(/:/g, '')}`; async function render() { @@ -54,7 +53,6 @@ export function MermaidBlock({ code }: MermaidBlockProps) { setError('Invalid diagram'); setSvg(null); } - // Clean up any leftover element mermaid may have created document.getElementById(`d${id}`)?.remove(); } } @@ -63,10 +61,9 @@ export function MermaidBlock({ code }: MermaidBlockProps) { return () => { cancelled = true; }; - }, [code]); + }, [code, instanceId]); if (error) { - // Fall back to plain code block return (
@@ -81,11 +78,7 @@ export function MermaidBlock({ code }: MermaidBlockProps) {
 
   return (
     
-
+
); diff --git a/frontend/src/components/MessageBubble.tsx b/frontend/src/components/MessageBubble.tsx index 079be085..6a58485d 100644 --- a/frontend/src/components/MessageBubble.tsx +++ b/frontend/src/components/MessageBubble.tsx @@ -10,6 +10,7 @@ import { CopyButton } from './CopyButton'; import { ShareButton } from './ShareButton'; import { ReadAloudButton } from './ReadAloudButton'; import { extractText } from '../lib/extractText'; +import { getMermaidCode } from '../lib/mermaid-detect'; import { MarkdownPreviewCard } from './MarkdownPreviewCard'; import { MermaidBlock } from './MermaidBlock'; @@ -112,15 +113,8 @@ export function TextBubble({ content, streaming = false, timestamp, readAloud }:
), pre: ({ children, ...props }: React.ComponentProps<'pre'>) => { - // Detect mermaid code blocks and render as diagrams - const child = React.Children.toArray(children)[0]; - if (React.isValidElement(child)) { - const className = (child.props as Record)?.className; - if (typeof className === 'string' && /language-mermaid/.test(className)) { - const text = extractText(children); - return ; - } - } + const mermaidCode = getMermaidCode(children); + if (mermaidCode !== null) return ; const text = extractText(children); return (
diff --git a/frontend/src/components/__tests__/MermaidBlock.test.ts b/frontend/src/components/__tests__/MermaidBlock.test.ts new file mode 100644 index 00000000..2066c5f0 --- /dev/null +++ b/frontend/src/components/__tests__/MermaidBlock.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +// Mock mermaid before importing the component +vi.mock('mermaid', () => ({ + default: { + initialize: vi.fn(), + render: vi.fn(), + }, +})); + +// Mock useId since renderToStaticMarkup doesn't fully support it +vi.mock('react', async () => { + const actual = await vi.importActual('react'); + return { ...actual, useId: () => ':test-id:' }; +}); + +import mermaid from 'mermaid'; +import { MermaidBlock } from '../MermaidBlock'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('MermaidBlock', () => { + it('renders null initially while waiting for mermaid.render', () => { + // mermaid.render returns a never-resolving promise (simulates pending) + vi.mocked(mermaid.render).mockReturnValue(new Promise(() => {})); + const html = renderToStaticMarkup(createElement(MermaidBlock, { code: 'graph TD; A-->B;' })); + // Should render nothing while svg is null and no error + expect(html).toBe(''); + }); + + it('renders fallback code block on render error', async () => { + vi.mocked(mermaid.render).mockRejectedValue(new Error('parse error')); + + // We can't easily test async state updates with renderToStaticMarkup, + // but we can verify the component doesn't crash + const html = renderToStaticMarkup(createElement(MermaidBlock, { code: 'invalid{{{' })); + expect(html).toBe(''); // Initial render before error resolves + }); + + it('does not call mermaid.initialize at import time (deferred to first render)', () => { + // Verify the deferred initialization pattern — initialize is called inside + // useEffect, not at module scope. renderToStaticMarkup skips effects. + vi.mocked(mermaid.render).mockReturnValue(new Promise(() => {})); + renderToStaticMarkup(createElement(MermaidBlock, { code: 'graph TD; A-->B;' })); + expect(mermaid.initialize).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/lib/markdown-config.tsx b/frontend/src/lib/markdown-config.tsx index b5542f0f..17d8a5a1 100644 --- a/frontend/src/lib/markdown-config.tsx +++ b/frontend/src/lib/markdown-config.tsx @@ -1,11 +1,10 @@ -import React from 'react'; import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; import type { Components } from 'react-markdown'; import type { PluggableList } from 'unified'; import { MermaidBlock } from '../components/MermaidBlock'; -import { extractText } from './extractText'; +import { getMermaidCode } from './mermaid-detect'; const sanitizeSchema = { ...defaultSchema, @@ -27,14 +26,8 @@ export const markdownComponents: Components = {
), pre: ({ children, ...props }) => { - const child = React.Children.toArray(children)[0]; - if (React.isValidElement(child)) { - const className = (child.props as Record)?.className; - if (typeof className === 'string' && /language-mermaid/.test(className)) { - const text = extractText(children); - return ; - } - } + const mermaidCode = getMermaidCode(children); + if (mermaidCode !== null) return ; return
{children}
; }, }; diff --git a/frontend/src/lib/mermaid-detect.ts b/frontend/src/lib/mermaid-detect.ts new file mode 100644 index 00000000..48410500 --- /dev/null +++ b/frontend/src/lib/mermaid-detect.ts @@ -0,0 +1,16 @@ +import React from 'react'; +import { extractText } from './extractText'; + +/** + * If children represent a mermaid code block (language-mermaid class on the + * code element), returns the raw mermaid source. Otherwise returns null. + */ +export function getMermaidCode(children: React.ReactNode): string | null { + const child = React.Children.toArray(children)[0]; + if (!React.isValidElement(child)) return null; + const className = (child.props as Record)?.className; + if (typeof className === 'string' && /language-mermaid/.test(className)) { + return extractText(children); + } + return null; +} From eb12ac1a3fd462a756b980c33c26c6148bc9cfb0 Mon Sep 17 00:00:00 2001 From: dimakis Date: Sat, 4 Jul 2026 01:09:57 +0100 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20address=20third=20Centaur=20review?= =?UTF-8?q?=20=E2=80=94=20regex=20anchor,=20DOM=20cleanup=20guard,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Anchor mermaid detection regex with \b word boundaries - Move DOM cleanup inside !cancelled guard - Improve MermaidBlock tests: jsdom environment, async render/error paths, securityLevel verification Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/MermaidBlock.tsx | 3 +- .../components/__tests__/MermaidBlock.test.ts | 51 +++++++++++-------- frontend/src/lib/mermaid-detect.ts | 2 +- 3 files changed, 34 insertions(+), 22 deletions(-) diff --git a/frontend/src/components/MermaidBlock.tsx b/frontend/src/components/MermaidBlock.tsx index bc9e20a7..9032363f 100644 --- a/frontend/src/components/MermaidBlock.tsx +++ b/frontend/src/components/MermaidBlock.tsx @@ -52,8 +52,9 @@ export function MermaidBlock({ code }: MermaidBlockProps) { if (!cancelled) { setError('Invalid diagram'); setSvg(null); + // Clean up any leftover element mermaid may have created on failure + document.getElementById(`d${id}`)?.remove(); } - document.getElementById(`d${id}`)?.remove(); } } diff --git a/frontend/src/components/__tests__/MermaidBlock.test.ts b/frontend/src/components/__tests__/MermaidBlock.test.ts index 2066c5f0..1c1aaf65 100644 --- a/frontend/src/components/__tests__/MermaidBlock.test.ts +++ b/frontend/src/components/__tests__/MermaidBlock.test.ts @@ -1,6 +1,8 @@ +// @vitest-environment jsdom import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; +import { render, act, cleanup } from '@testing-library/react'; // Mock mermaid before importing the component vi.mock('mermaid', () => ({ @@ -10,42 +12,51 @@ vi.mock('mermaid', () => ({ }, })); -// Mock useId since renderToStaticMarkup doesn't fully support it -vi.mock('react', async () => { - const actual = await vi.importActual('react'); - return { ...actual, useId: () => ':test-id:' }; -}); - import mermaid from 'mermaid'; import { MermaidBlock } from '../MermaidBlock'; beforeEach(() => { vi.clearAllMocks(); + cleanup(); }); describe('MermaidBlock', () => { it('renders null initially while waiting for mermaid.render', () => { - // mermaid.render returns a never-resolving promise (simulates pending) vi.mocked(mermaid.render).mockReturnValue(new Promise(() => {})); const html = renderToStaticMarkup(createElement(MermaidBlock, { code: 'graph TD; A-->B;' })); - // Should render nothing while svg is null and no error expect(html).toBe(''); }); - it('renders fallback code block on render error', async () => { - vi.mocked(mermaid.render).mockRejectedValue(new Error('parse error')); - - // We can't easily test async state updates with renderToStaticMarkup, - // but we can verify the component doesn't crash - const html = renderToStaticMarkup(createElement(MermaidBlock, { code: 'invalid{{{' })); - expect(html).toBe(''); // Initial render before error resolves - }); - - it('does not call mermaid.initialize at import time (deferred to first render)', () => { - // Verify the deferred initialization pattern — initialize is called inside - // useEffect, not at module scope. renderToStaticMarkup skips effects. + it('does not call mermaid.initialize at import time (deferred to useEffect)', () => { vi.mocked(mermaid.render).mockReturnValue(new Promise(() => {})); renderToStaticMarkup(createElement(MermaidBlock, { code: 'graph TD; A-->B;' })); expect(mermaid.initialize).not.toHaveBeenCalled(); }); + + it('renders SVG and initializes with securityLevel strict', async () => { + vi.mocked(mermaid.render).mockResolvedValue({ + svg: 'diagram', + }); + await act(async () => { + render(createElement(MermaidBlock, { code: 'graph TD; A-->B;' })); + }); + // Verify rendered output + const block = document.querySelector('.mermaid-block-svg'); + expect(block).not.toBeNull(); + expect(block!.innerHTML).toContain('diagram'); + // Verify security config on first initialization + expect(mermaid.initialize).toHaveBeenCalledWith( + expect.objectContaining({ securityLevel: 'strict' }), + ); + }); + + it('renders fallback code block on render error', async () => { + vi.mocked(mermaid.render).mockRejectedValue(new Error('parse error')); + await act(async () => { + render(createElement(MermaidBlock, { code: 'invalid{{{' })); + }); + const wrapper = document.querySelector('.code-block-wrapper'); + expect(wrapper).not.toBeNull(); + expect(wrapper!.textContent).toContain('invalid{{{'); + }); }); diff --git a/frontend/src/lib/mermaid-detect.ts b/frontend/src/lib/mermaid-detect.ts index 48410500..e77e9798 100644 --- a/frontend/src/lib/mermaid-detect.ts +++ b/frontend/src/lib/mermaid-detect.ts @@ -9,7 +9,7 @@ export function getMermaidCode(children: React.ReactNode): string | null { const child = React.Children.toArray(children)[0]; if (!React.isValidElement(child)) return null; const className = (child.props as Record)?.className; - if (typeof className === 'string' && /language-mermaid/.test(className)) { + if (typeof className === 'string' && /\blanguage-mermaid\b/.test(className)) { return extractText(children); } return null; From b754ab40cec913f79c20320d8338cdab524d34f0 Mon Sep 17 00:00:00 2001 From: dimakis Date: Sat, 4 Jul 2026 11:52:42 +0100 Subject: [PATCH 5/8] fix: correct MermaidBlock test mock to match RenderResult type Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/__tests__/MermaidBlock.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/__tests__/MermaidBlock.test.ts b/frontend/src/components/__tests__/MermaidBlock.test.ts index 1c1aaf65..60ac902e 100644 --- a/frontend/src/components/__tests__/MermaidBlock.test.ts +++ b/frontend/src/components/__tests__/MermaidBlock.test.ts @@ -36,7 +36,9 @@ describe('MermaidBlock', () => { it('renders SVG and initializes with securityLevel strict', async () => { vi.mocked(mermaid.render).mockResolvedValue({ svg: 'diagram', - }); + diagramType: 'flowchart', + bindFunctions: undefined, + } as Awaited>); await act(async () => { render(createElement(MermaidBlock, { code: 'graph TD; A-->B;' })); }); From 4596532913bb85235b0d50180d165827a20fad30 Mon Sep 17 00:00:00 2001 From: dimakis Date: Sat, 4 Jul 2026 12:04:27 +0100 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20address=20fourth=20Centaur=20review?= =?UTF-8?q?=20=E2=80=94=20dynamic=20import,=20test=20reset,=20edge=20cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Convert mermaid to dynamic import() for code splitting (~1MB+ kept out of main bundle, loaded only when a diagram is encountered) - Export _resetMermaidInit for test cleanup between runs - Use exact class match (split+includes) instead of regex for mermaid detection — prevents false positives like language-mermaid-extended - Add comprehensive getMermaidCode edge case tests Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/MermaidBlock.tsx | 51 +++++++++--------- .../components/__tests__/MermaidBlock.test.ts | 52 ++++++++++--------- .../src/lib/__tests__/mermaid-detect.test.ts | 39 ++++++++++++++ frontend/src/lib/mermaid-detect.ts | 2 +- 4 files changed, 95 insertions(+), 49 deletions(-) create mode 100644 frontend/src/lib/__tests__/mermaid-detect.test.ts diff --git a/frontend/src/components/MermaidBlock.tsx b/frontend/src/components/MermaidBlock.tsx index 9032363f..e999e1e5 100644 --- a/frontend/src/components/MermaidBlock.tsx +++ b/frontend/src/components/MermaidBlock.tsx @@ -1,30 +1,11 @@ import { useEffect, useId, useState } from 'react'; -import mermaid from 'mermaid'; import { CopyButton } from './CopyButton'; let mermaidInitialized = false; -function ensureMermaidInit() { - if (mermaidInitialized) return; - mermaidInitialized = true; - mermaid.initialize({ - startOnLoad: false, - securityLevel: 'strict', - theme: 'dark', - themeVariables: { - darkMode: true, - background: '#1e1e2e', - primaryColor: '#7c3aed', - primaryTextColor: '#e2e8f0', - primaryBorderColor: '#6366f1', - lineColor: '#94a3b8', - secondaryColor: '#374151', - tertiaryColor: '#1f2937', - noteBkgColor: '#374151', - noteTextColor: '#e2e8f0', - fontFamily: 'inherit', - }, - }); +/** Exported for test cleanup only. */ +export function _resetMermaidInit() { + mermaidInitialized = false; } interface MermaidBlockProps { @@ -37,12 +18,35 @@ export function MermaidBlock({ code }: MermaidBlockProps) { const [svg, setSvg] = useState(null); useEffect(() => { - ensureMermaidInit(); let cancelled = false; const id = `mermaid-${instanceId.replace(/:/g, '')}`; async function render() { try { + // Dynamic import keeps mermaid (~1MB+ with d3/katex/cytoscape) out of + // the main bundle — only loaded when a mermaid diagram is encountered. + const { default: mermaid } = await import('mermaid'); + if (!mermaidInitialized) { + mermaidInitialized = true; + mermaid.initialize({ + startOnLoad: false, + securityLevel: 'strict', + theme: 'dark', + themeVariables: { + darkMode: true, + background: '#1e1e2e', + primaryColor: '#7c3aed', + primaryTextColor: '#e2e8f0', + primaryBorderColor: '#6366f1', + lineColor: '#94a3b8', + secondaryColor: '#374151', + tertiaryColor: '#1f2937', + noteBkgColor: '#374151', + noteTextColor: '#e2e8f0', + fontFamily: 'inherit', + }, + }); + } const { svg: rendered } = await mermaid.render(id, code); if (!cancelled) { setSvg(rendered); @@ -52,7 +56,6 @@ export function MermaidBlock({ code }: MermaidBlockProps) { if (!cancelled) { setError('Invalid diagram'); setSvg(null); - // Clean up any leftover element mermaid may have created on failure document.getElementById(`d${id}`)?.remove(); } } diff --git a/frontend/src/components/__tests__/MermaidBlock.test.ts b/frontend/src/components/__tests__/MermaidBlock.test.ts index 60ac902e..f38f2721 100644 --- a/frontend/src/components/__tests__/MermaidBlock.test.ts +++ b/frontend/src/components/__tests__/MermaidBlock.test.ts @@ -1,59 +1,47 @@ // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createElement } from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; import { render, act, cleanup } from '@testing-library/react'; -// Mock mermaid before importing the component +const mockInitialize = vi.fn(); +const mockRender = vi.fn(); + +// Mock the dynamic import('mermaid') that MermaidBlock uses vi.mock('mermaid', () => ({ default: { - initialize: vi.fn(), - render: vi.fn(), + initialize: mockInitialize, + render: mockRender, }, })); -import mermaid from 'mermaid'; -import { MermaidBlock } from '../MermaidBlock'; +import { MermaidBlock, _resetMermaidInit } from '../MermaidBlock'; beforeEach(() => { vi.clearAllMocks(); + _resetMermaidInit(); cleanup(); }); describe('MermaidBlock', () => { - it('renders null initially while waiting for mermaid.render', () => { - vi.mocked(mermaid.render).mockReturnValue(new Promise(() => {})); - const html = renderToStaticMarkup(createElement(MermaidBlock, { code: 'graph TD; A-->B;' })); - expect(html).toBe(''); - }); - - it('does not call mermaid.initialize at import time (deferred to useEffect)', () => { - vi.mocked(mermaid.render).mockReturnValue(new Promise(() => {})); - renderToStaticMarkup(createElement(MermaidBlock, { code: 'graph TD; A-->B;' })); - expect(mermaid.initialize).not.toHaveBeenCalled(); - }); - it('renders SVG and initializes with securityLevel strict', async () => { - vi.mocked(mermaid.render).mockResolvedValue({ + mockRender.mockResolvedValue({ svg: 'diagram', diagramType: 'flowchart', bindFunctions: undefined, - } as Awaited>); + }); await act(async () => { render(createElement(MermaidBlock, { code: 'graph TD; A-->B;' })); }); - // Verify rendered output const block = document.querySelector('.mermaid-block-svg'); expect(block).not.toBeNull(); expect(block!.innerHTML).toContain('diagram'); - // Verify security config on first initialization - expect(mermaid.initialize).toHaveBeenCalledWith( + expect(mockInitialize).toHaveBeenCalledWith( expect.objectContaining({ securityLevel: 'strict' }), ); }); it('renders fallback code block on render error', async () => { - vi.mocked(mermaid.render).mockRejectedValue(new Error('parse error')); + mockRender.mockRejectedValue(new Error('parse error')); await act(async () => { render(createElement(MermaidBlock, { code: 'invalid{{{' })); }); @@ -61,4 +49,20 @@ describe('MermaidBlock', () => { expect(wrapper).not.toBeNull(); expect(wrapper!.textContent).toContain('invalid{{{'); }); + + it('only initializes mermaid once across multiple renders', async () => { + mockRender.mockResolvedValue({ + svg: 'a', + diagramType: 'flowchart', + bindFunctions: undefined, + }); + await act(async () => { + render(createElement(MermaidBlock, { code: 'graph TD; A-->B;' })); + }); + cleanup(); + await act(async () => { + render(createElement(MermaidBlock, { code: 'graph LR; X-->Y;' })); + }); + expect(mockInitialize).toHaveBeenCalledTimes(1); + }); }); diff --git a/frontend/src/lib/__tests__/mermaid-detect.test.ts b/frontend/src/lib/__tests__/mermaid-detect.test.ts new file mode 100644 index 00000000..1c506d4d --- /dev/null +++ b/frontend/src/lib/__tests__/mermaid-detect.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { createElement } from 'react'; +import { getMermaidCode } from '../mermaid-detect'; + +describe('getMermaidCode', () => { + it('returns code for language-mermaid class', () => { + const code = createElement('code', { className: 'language-mermaid' }, 'graph TD; A-->B;'); + expect(getMermaidCode(code)).toBe('graph TD; A-->B;'); + }); + + it('returns code when language-mermaid is among multiple classes', () => { + const code = createElement('code', { className: 'hljs language-mermaid' }, 'graph LR;'); + expect(getMermaidCode(code)).toBe('graph LR;'); + }); + + it('returns null for non-mermaid language', () => { + const code = createElement('code', { className: 'language-python' }, 'print("hi")'); + expect(getMermaidCode(code)).toBeNull(); + }); + + it('returns null when className is missing', () => { + const code = createElement('code', null, 'plain text'); + expect(getMermaidCode(code)).toBeNull(); + }); + + it('returns null for null/undefined children', () => { + expect(getMermaidCode(null)).toBeNull(); + expect(getMermaidCode(undefined)).toBeNull(); + }); + + it('returns null for non-element children', () => { + expect(getMermaidCode('just a string')).toBeNull(); + }); + + it('does not match language-mermaid-extended (word boundary)', () => { + const code = createElement('code', { className: 'language-mermaid-extended' }, 'test'); + expect(getMermaidCode(code)).toBeNull(); + }); +}); diff --git a/frontend/src/lib/mermaid-detect.ts b/frontend/src/lib/mermaid-detect.ts index e77e9798..353d2fdd 100644 --- a/frontend/src/lib/mermaid-detect.ts +++ b/frontend/src/lib/mermaid-detect.ts @@ -9,7 +9,7 @@ export function getMermaidCode(children: React.ReactNode): string | null { const child = React.Children.toArray(children)[0]; if (!React.isValidElement(child)) return null; const className = (child.props as Record)?.className; - if (typeof className === 'string' && /\blanguage-mermaid\b/.test(className)) { + if (typeof className === 'string' && className.split(/\s+/).includes('language-mermaid')) { return extractText(children); } return null; From 4c633a61e519f3ac6aa641d8e17ad478cd4c29f6 Mon Sep 17 00:00:00 2001 From: dimakis Date: Sat, 4 Jul 2026 12:18:15 +0100 Subject: [PATCH 7/8] fix(frontend): address 5th Centaur review findings - Use ref for currentPath to prevent useMemo invalidation on navigation - Remove test-only _resetMermaidInit export, use vi.resetModules() instead - Add version comment for mermaid DOM cleanup convention - Add cancellation/cleanup test for unmount during in-flight render - Add useMemo identity preservation test for mdComponents stability - Fix pre-existing anchor test failures (drill into span wrapper) Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/MermaidBlock.tsx | 8 ++- frontend/src/components/MessageBubble.tsx | 13 +++-- .../components/__tests__/MermaidBlock.test.ts | 51 +++++++++++++++++-- .../__tests__/MessageBubble.test.ts | 23 +++++++-- 4 files changed, 79 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/MermaidBlock.tsx b/frontend/src/components/MermaidBlock.tsx index e999e1e5..1841452e 100644 --- a/frontend/src/components/MermaidBlock.tsx +++ b/frontend/src/components/MermaidBlock.tsx @@ -3,11 +3,6 @@ import { CopyButton } from './CopyButton'; let mermaidInitialized = false; -/** Exported for test cleanup only. */ -export function _resetMermaidInit() { - mermaidInitialized = false; -} - interface MermaidBlockProps { code: string; } @@ -56,6 +51,9 @@ export function MermaidBlock({ code }: MermaidBlockProps) { if (!cancelled) { setError('Invalid diagram'); setSvg(null); + // Mermaid inserts a temporary element with id `d` during render. + // On error, it may leave this element behind. Convention verified + // against mermaid v11 (mermaid-js/mermaid). document.getElementById(`d${id}`)?.remove(); } } diff --git a/frontend/src/components/MessageBubble.tsx b/frontend/src/components/MessageBubble.tsx index 6a58485d..86b807b7 100644 --- a/frontend/src/components/MessageBubble.tsx +++ b/frontend/src/components/MessageBubble.tsx @@ -90,11 +90,16 @@ export function TextBubble({ content, streaming = false, timestamp, readAloud }: const navigate = useNavigate(); const location = useLocation(); const processed = streaming ? content : linkifyFilePaths(content); - const currentPath = location.pathname + location.search; const [collapsed, setCollapsed] = useState(true); const [isLong, setIsLong] = useState(false); const contentRef = useRef(null); + // Use a ref for currentPath so the useMemo components stay stable across + // location changes (query params, navigation). The onClick handler reads + // the ref at click time, not at memo creation time. + const currentPathRef = useRef(location.pathname + location.search); + currentPathRef.current = location.pathname + location.search; + useEffect(() => { if (contentRef.current && !streaming) { setIsLong(contentRef.current.scrollHeight > COLLAPSE_HEIGHT); @@ -105,6 +110,8 @@ export function TextBubble({ content, streaming = false, timestamp, readAloud }: // Memoize components so react-markdown preserves component instances // (e.g. MarkdownPreviewCard expanded state) across parent re-renders. + // navigate is stable (from react-router), currentPath uses a ref to avoid + // invalidating the memo on location changes. const mdComponents = useMemo( () => ({ table: ({ children, ...props }: React.ComponentProps<'table'>) => ( @@ -149,7 +156,7 @@ export function TextBubble({ content, streaming = false, timestamp, readAloud }: onClick={(e) => { e.preventDefault(); navigate( - `/files?path=${encodeURIComponent(filePath)}&from=${encodeURIComponent(currentPath)}`, + `/files?path=${encodeURIComponent(filePath)}&from=${encodeURIComponent(currentPathRef.current)}`, ); }} > @@ -166,7 +173,7 @@ export function TextBubble({ content, streaming = false, timestamp, readAloud }: ); }, }), - [navigate, currentPath], + [navigate], ); return ( diff --git a/frontend/src/components/__tests__/MermaidBlock.test.ts b/frontend/src/components/__tests__/MermaidBlock.test.ts index f38f2721..164a01af 100644 --- a/frontend/src/components/__tests__/MermaidBlock.test.ts +++ b/frontend/src/components/__tests__/MermaidBlock.test.ts @@ -14,14 +14,19 @@ vi.mock('mermaid', () => ({ }, })); -import { MermaidBlock, _resetMermaidInit } from '../MermaidBlock'; - beforeEach(() => { vi.clearAllMocks(); - _resetMermaidInit(); cleanup(); }); +// Each test that needs a fresh module-level `mermaidInitialized` flag uses +// vi.resetModules() + dynamic import, avoiding a test-only export. +async function freshMermaidBlock() { + vi.resetModules(); + const mod = await import('../MermaidBlock'); + return mod.MermaidBlock; +} + describe('MermaidBlock', () => { it('renders SVG and initializes with securityLevel strict', async () => { mockRender.mockResolvedValue({ @@ -29,6 +34,7 @@ describe('MermaidBlock', () => { diagramType: 'flowchart', bindFunctions: undefined, }); + const MermaidBlock = await freshMermaidBlock(); await act(async () => { render(createElement(MermaidBlock, { code: 'graph TD; A-->B;' })); }); @@ -42,6 +48,7 @@ describe('MermaidBlock', () => { it('renders fallback code block on render error', async () => { mockRender.mockRejectedValue(new Error('parse error')); + const MermaidBlock = await freshMermaidBlock(); await act(async () => { render(createElement(MermaidBlock, { code: 'invalid{{{' })); }); @@ -56,13 +63,47 @@ describe('MermaidBlock', () => { diagramType: 'flowchart', bindFunctions: undefined, }); + // Use the same module instance for both renders (no resetModules) + const { MermaidBlock } = await import('../MermaidBlock'); + // Reset init state via fresh module for this test + const Fresh = await freshMermaidBlock(); await act(async () => { - render(createElement(MermaidBlock, { code: 'graph TD; A-->B;' })); + render(createElement(Fresh, { code: 'graph TD; A-->B;' })); }); cleanup(); + // Re-import from cache (same module instance, flag already set) + const { MermaidBlock: Same } = await import('../MermaidBlock'); await act(async () => { - render(createElement(MermaidBlock, { code: 'graph LR; X-->Y;' })); + render(createElement(Same, { code: 'graph LR; X-->Y;' })); }); expect(mockInitialize).toHaveBeenCalledTimes(1); }); + + it('does not update state after unmount (cancellation)', async () => { + // Simulate a slow render that resolves after the component unmounts + let resolveRender: (v: unknown) => void; + const renderPromise = new Promise((resolve) => { + resolveRender = resolve; + }); + mockRender.mockReturnValue(renderPromise); + + const MermaidBlock = await freshMermaidBlock(); + const { unmount } = render(createElement(MermaidBlock, { code: 'graph TD; A-->B;' })); + + // Unmount before render resolves — sets cancelled = true + unmount(); + + // Now resolve the render — the cancelled flag should prevent setSvg + await act(async () => { + resolveRender!({ + svg: 'late', + diagramType: 'flowchart', + bindFunctions: undefined, + }); + }); + + // No SVG should appear in the document (component is unmounted and + // the state update was skipped) + expect(document.querySelector('.mermaid-block-svg')).toBeNull(); + }); }); diff --git a/frontend/src/components/__tests__/MessageBubble.test.ts b/frontend/src/components/__tests__/MessageBubble.test.ts index cb53d6fc..68695a23 100644 --- a/frontend/src/components/__tests__/MessageBubble.test.ts +++ b/frontend/src/components/__tests__/MessageBubble.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { describe, it, expect, vi } from 'vitest'; // Mock react-markdown and remark-gfm before importing the component @@ -33,6 +34,7 @@ vi.mock('react-router-dom', () => ({ import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; +import { render } from '@testing-library/react'; import { MessageBubble, TextBubble, UserBubble } from '../MessageBubble'; import { FILE_SCHEME } from '../../lib/file-paths'; import type { FinishedMessage } from '../../types/chat'; @@ -119,9 +121,10 @@ describe('MessageBubble', () => { // Simulate click to verify navigate is called with the correct route const clickEvent = { preventDefault: vi.fn() }; - // Extract onClick from rendered component — render via createElement to get props + // The anchor wraps in with as first child const rendered = anchor({ href: fileHref, children: '/tmp/output.md' }); - rendered.props.onClick(clickEvent); + const innerA = rendered.props.children[0]; + innerA.props.onClick(clickEvent); expect(clickEvent.preventDefault).toHaveBeenCalled(); expect(mockNavigate).toHaveBeenCalledWith( @@ -311,7 +314,8 @@ describe('TextBubble markdown preview card promotion', () => { const anchor = capturedComponents!.a; const fileHref = `${FILE_SCHEME}${encodeURIComponent('/tmp/notes.md')}`; const rendered = anchor({ href: fileHref, children: '/tmp/notes.md' }); - expect(rendered.props['data-file-path']).toBe('/tmp/notes.md'); + const innerA = rendered.props.children[0]; + expect(innerA.props['data-file-path']).toBe('/tmp/notes.md'); }); }); @@ -358,3 +362,16 @@ describe('MessageBubble legacy adapter forwards timestamps', () => { expect(html).not.toContain('msg-timestamp'); }); }); + +describe('TextBubble useMemo identity', () => { + it('preserves mdComponents reference across re-renders with same deps', () => { + const { rerender } = render(createElement(TextBubble, { content: 'first' })); + const firstComponents = capturedComponents; + + rerender(createElement(TextBubble, { content: 'second' })); + const secondComponents = capturedComponents; + + // useMemo should return the same object reference since deps (navigate) are stable + expect(secondComponents).toBe(firstComponents); + }); +}); From 946f7eacfad04a5a10e7f33fc81e1d1e316e75e0 Mon Sep 17 00:00:00 2001 From: dimakis Date: Sat, 4 Jul 2026 12:21:25 +0100 Subject: [PATCH 8/8] fix(test): remove unused MermaidBlock import in init test Lint flagged unused import from pre-freshMermaidBlock() approach. Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/__tests__/MermaidBlock.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frontend/src/components/__tests__/MermaidBlock.test.ts b/frontend/src/components/__tests__/MermaidBlock.test.ts index 164a01af..3d86a893 100644 --- a/frontend/src/components/__tests__/MermaidBlock.test.ts +++ b/frontend/src/components/__tests__/MermaidBlock.test.ts @@ -63,9 +63,7 @@ describe('MermaidBlock', () => { diagramType: 'flowchart', bindFunctions: undefined, }); - // Use the same module instance for both renders (no resetModules) - const { MermaidBlock } = await import('../MermaidBlock'); - // Reset init state via fresh module for this test + // Get a fresh module (resets mermaidInitialized flag) const Fresh = await freshMermaidBlock(); await act(async () => { render(createElement(Fresh, { code: 'graph TD; A-->B;' }));