diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..5ee7abd --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +pnpm exec lint-staged diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..d14e2b6 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +dist +node_modules +pnpm-lock.yaml +*.yaml +*.md diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..f528c69 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "singleQuote": true, + "semi": false, + "tabWidth": 2, + "trailingComma": "all", + "printWidth": 100, + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/Makefile b/Makefile index 956a6ab..87f4a45 100644 --- a/Makefile +++ b/Makefile @@ -99,11 +99,16 @@ new-lockfile: ## Regenerate the pnpm lockfile tree: ## Show project structure (requires 'tree' command) tree -I 'node_modules|dist|.git' --dirsfirst -# loc: ## Count lines of source code -# @echo "" -# @echo " Lines of code by package:" -# @echo " ─────────────────────────" -# @printf " core: " && find packages/core/src -name '*.ts' | xargs cat | wc -l -# @printf " renderer: " && find packages/renderer/src -name '*.ts' -o -name '*.tsx' | xargs cat | wc -l -# @printf " web: " && find apps/web/src -name '*.ts' -o -name '*.tsx' | xargs cat | wc -l -# @echo "" +# ── Linting & formatting ────────────────────────────────────────── + +lint: ## Run ESLint + $(PNPM) run lint + +lint-fix: ## Run ESLint with auto-fix + $(PNPM) run lint:fix + +format: ## Format all files with Prettier + $(PNPM) run format + +format-check: ## Check formatting without writing + $(PNPM) run format:check diff --git a/apps/.gitattributes b/apps/.gitattributes deleted file mode 100644 index e69de29..0000000 diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 706baf9..8deef5f 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,125 +1,157 @@ -import React, { useState, useMemo, useCallback } from "react"; -import { parse, layout } from "@homelab-stackdoc/core"; -import type { PositionedGraph, ValidationError, Device } from "@homelab-stackdoc/core"; -import { YamlEditor } from "./components/YamlEditor"; -import { PreviewPane } from "./components/PreviewPane"; -import { SAMPLE_YAML } from "./sampleYaml"; +import React, { useState, useMemo, useCallback } from 'react' +import { parse, layout } from '@homelab-stackdoc/core' +import { PreviewPane } from './components/PreviewPane' +import SAMPLE_YAML from './sample.yaml?raw' +import { YamlEditor } from './components/YamlEditor' +import type { Device } from '@homelab-stackdoc/core' -/** Recursively collects all devices (including children) into a flat map */ function buildDeviceMap(devices: Device[]): Map { - const map = new Map(); + const map = new Map() const walk = (devs: Device[]) => { for (const d of devs) { - map.set(d.id, d); - if (d.children) walk(d.children); + map.set(d.id, d) + if (d.children) walk(d.children) } - }; - walk(devices); - return map; + } + walk(devices) + return map } -export const App: React.FC = () => { - const [yaml, setYaml] = useState(SAMPLE_YAML); - const [splitRatio, setSplitRatio] = useState(0.15); - const [resizing, setResizing] = useState(false); - const [expanded, setExpanded] = useState>(new Set()); +const toggleButtonStyle: React.CSSProperties = { + position: 'absolute', + top: 52, + left: 8, + zIndex: 20, + width: 32, + height: 32, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + background: 'rgba(12, 21, 39, 0.9)', + border: '1px solid rgba(0, 229, 255, 0.12)', + borderRadius: 6, + color: '#78909c', + cursor: 'pointer', + fontFamily: "'JetBrains Mono', monospace", + fontSize: 14, + padding: 0, + transition: 'all 0.15s', +} - const toggleExpand = useCallback((id: string) => { - setExpanded((prev) => { - const next = new Set(prev); - if (next.has(id)) { - next.delete(id); - } else { - next.add(id); - } - return next; - }); - }, []); +export const App: React.FC = () => { + const [yaml, setYaml] = useState(SAMPLE_YAML) + const [splitRatio, setSplitRatio] = useState(0.2) + const [resizing, setResizing] = useState(false) + const [editorVisible, setEditorVisible] = useState(true) - // Core pipeline: parse → validate → layout - const { graph, errors, deviceMap } = useMemo<{ - graph: PositionedGraph | null; - errors: ValidationError[]; - deviceMap: Map; - }>(() => { - const result = parse(yaml); + const { graph, errors, deviceMap, connections } = useMemo(() => { + const result = parse(yaml) if (!result.ok) { - return { graph: null, errors: result.errors, deviceMap: new Map() }; + return { graph: null, errors: result.errors, deviceMap: new Map(), connections: [] } } try { - const positioned = layout(result.document, { expanded }); - const dMap = buildDeviceMap(result.document.devices); - return { graph: positioned, errors: result.warnings, deviceMap: dMap }; + const positioned = layout(result.document) + const dMap = buildDeviceMap(result.document.devices) + return { + graph: positioned, + errors: result.warnings, + deviceMap: dMap, + connections: result.document.connections ?? [], + } } catch (e) { return { graph: null, errors: [ { - path: "", + path: '', message: `Layout error: ${e instanceof Error ? e.message : String(e)}`, - severity: "error" as const, + severity: 'error' as const, }, ], deviceMap: new Map(), - }; + connections: [], + } } - }, [yaml, expanded]); + }, [yaml]) - // Split-pane resizer const onResizeStart = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - setResizing(true); - + e.preventDefault() + setResizing(true) const onMove = (moveEvent: MouseEvent) => { - const ratio = moveEvent.clientX / window.innerWidth; - setSplitRatio(Math.min(0.6, Math.max(0.15, ratio))); - }; + const ratio = moveEvent.clientX / window.innerWidth + setSplitRatio(Math.min(0.6, Math.max(0.15, ratio))) + } const onUp = () => { - setResizing(false); - window.removeEventListener("mousemove", onMove); - window.removeEventListener("mouseup", onUp); - }; - window.addEventListener("mousemove", onMove); - window.addEventListener("mouseup", onUp); - }, []); + setResizing(false) + window.removeEventListener('mousemove', onMove) + window.removeEventListener('mouseup', onUp) + } + window.addEventListener('mousemove', onMove) + window.addEventListener('mouseup', onUp) + }, []) return (
-
- -
+ {/* Editor pane */} + {editorVisible && ( + <> +
+ +
+
+ + )} -
+ {/* Canvas pane */} +
+ {/* Editor toggle button */} + -
- ); -}; + ) +} diff --git a/apps/web/src/components/CodeMirrorEditor.tsx b/apps/web/src/components/CodeMirrorEditor.tsx index f2b3806..4e35128 100644 --- a/apps/web/src/components/CodeMirrorEditor.tsx +++ b/apps/web/src/components/CodeMirrorEditor.tsx @@ -1,132 +1,140 @@ -import React, { useRef, useEffect } from "react"; -import { EditorState } from "@codemirror/state"; -import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter } from "@codemirror/view"; -import { defaultKeymap, indentWithTab, history, historyKeymap } from "@codemirror/commands"; -import { syntaxHighlighting, bracketMatching, foldGutter, HighlightStyle } from "@codemirror/language"; -import { yaml } from "@codemirror/lang-yaml"; -import { searchKeymap, highlightSelectionMatches } from "@codemirror/search"; -import { autocompletion } from "@codemirror/autocomplete"; -import { lintGutter } from "@codemirror/lint"; -import { tags } from "@lezer/highlight"; +import { autocompletion } from '@codemirror/autocomplete' +import { defaultKeymap, indentWithTab, history, historyKeymap } from '@codemirror/commands' +import { EditorState } from '@codemirror/state' +import { + EditorView, + keymap, + lineNumbers, + highlightActiveLine, + highlightActiveLineGutter, +} from '@codemirror/view' +import { lintGutter } from '@codemirror/lint' +import React, { useRef, useEffect } from 'react' +import { searchKeymap, highlightSelectionMatches } from '@codemirror/search' +import { + syntaxHighlighting, + bracketMatching, + foldGutter, + HighlightStyle, +} from '@codemirror/language' +import { tags } from '@lezer/highlight' +import { yaml } from '@codemirror/lang-yaml' interface CodeMirrorEditorProps { - value: string; - onChange: (value: string) => void; + value: string + onChange: (value: string) => void } const theme = EditorView.theme({ - "&": { - height: "100%", - fontSize: "13px", + '&': { + height: '100%', + fontSize: '13px', fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', monospace", - backgroundColor: "transparent", + backgroundColor: 'transparent', }, - ".cm-content": { - caretColor: "#00e5ff", - padding: "16px 0", + '.cm-content': { + caretColor: '#00e5ff', + padding: '16px 0', }, - ".cm-cursor": { - borderLeftColor: "#00e5ff", - borderLeftWidth: "2px", + '.cm-cursor': { + borderLeftColor: '#00e5ff', + borderLeftWidth: '2px', }, - "&.cm-focused .cm-cursor": { - borderLeftColor: "#00e5ff", + '&.cm-focused .cm-cursor': { + borderLeftColor: '#00e5ff', }, - "&.cm-focused .cm-selectionBackground, .cm-selectionBackground": { - backgroundColor: "rgba(0, 229, 255, 0.15) !important", + '&.cm-focused .cm-selectionBackground, .cm-selectionBackground': { + backgroundColor: 'rgba(0, 229, 255, 0.15) !important', }, - "&.cm-focused": { - outline: "none", + '&.cm-focused': { + outline: 'none', }, - ".cm-gutters": { - backgroundColor: "transparent", - borderRight: "1px solid rgba(0, 229, 255, 0.08)", - color: "#546e7a", - minWidth: "40px", + '.cm-gutters': { + backgroundColor: 'transparent', + borderRight: '1px solid rgba(0, 229, 255, 0.08)', + color: '#546e7a', + minWidth: '40px', }, - ".cm-activeLineGutter": { - backgroundColor: "rgba(0, 229, 255, 0.06)", - color: "#b0bec5", + '.cm-activeLineGutter': { + backgroundColor: 'rgba(0, 229, 255, 0.06)', + color: '#b0bec5', }, - ".cm-activeLine": { - backgroundColor: "rgba(0, 229, 255, 0.04)", + '.cm-activeLine': { + backgroundColor: 'rgba(0, 229, 255, 0.04)', }, - ".cm-foldGutter .cm-gutterElement": { - color: "#546e7a", - cursor: "pointer", + '.cm-foldGutter .cm-gutterElement': { + color: '#546e7a', + cursor: 'pointer', }, - ".cm-line": { - padding: "0 16px", + '.cm-line': { + padding: '0 16px', }, - ".cm-scroller": { - overflow: "auto", + '.cm-scroller': { + overflow: 'auto', }, - ".cm-scroller::-webkit-scrollbar": { - width: "6px", + '.cm-scroller::-webkit-scrollbar': { + width: '6px', }, - ".cm-scroller::-webkit-scrollbar-track": { - background: "transparent", + '.cm-scroller::-webkit-scrollbar-track': { + background: 'transparent', }, - ".cm-scroller::-webkit-scrollbar-thumb": { - background: "rgba(0, 229, 255, 0.15)", - borderRadius: "3px", + '.cm-scroller::-webkit-scrollbar-thumb': { + background: 'rgba(0, 229, 255, 0.15)', + borderRadius: '3px', }, -}); +}) const highlightColors = HighlightStyle.define([ - { tag: tags.keyword, color: "#00e5ff", fontWeight: "bold" }, - { tag: tags.atom, color: "#d500f9" }, - { tag: tags.bool, color: "#d500f9" }, - { tag: tags.null, color: "#78909c" }, - { tag: tags.number, color: "#ffab00" }, - { tag: tags.string, color: "#a5d6a7" }, - { tag: tags.comment, color: "#546e7a", fontStyle: "italic" }, - { tag: tags.meta, color: "#90a4ae" }, - { tag: tags.propertyName, color: "#4dd0e1" }, - { tag: tags.definition(tags.propertyName), color: "#4dd0e1" }, - { tag: tags.typeName, color: "#ffab00" }, - { tag: tags.punctuation, color: "#78909c" }, - { tag: tags.separator, color: "#78909c" }, - { tag: tags.operator, color: "#78909c" }, - { tag: tags.variableName, color: "#e0f7fa" }, - { tag: tags.content, color: "#e0f7fa" }, - { tag: tags.name, color: "#4dd0e1" }, -]); + { tag: tags.keyword, color: '#00e5ff', fontWeight: 'bold' }, + { tag: tags.atom, color: '#d500f9' }, + { tag: tags.bool, color: '#d500f9' }, + { tag: tags.null, color: '#78909c' }, + { tag: tags.number, color: '#ffab00' }, + { tag: tags.string, color: '#a5d6a7' }, + { tag: tags.comment, color: '#546e7a', fontStyle: 'italic' }, + { tag: tags.meta, color: '#90a4ae' }, + { tag: tags.propertyName, color: '#4dd0e1' }, + { tag: tags.definition(tags.propertyName), color: '#4dd0e1' }, + { tag: tags.typeName, color: '#ffab00' }, + { tag: tags.punctuation, color: '#78909c' }, + { tag: tags.separator, color: '#78909c' }, + { tag: tags.operator, color: '#78909c' }, + { tag: tags.variableName, color: '#e0f7fa' }, + { tag: tags.content, color: '#e0f7fa' }, + { tag: tags.name, color: '#4dd0e1' }, +]) const syntaxColors = EditorView.theme({ // YAML keys - ".cm-propertyName": { color: "#00e5ff" }, - ".cm-string": { color: "#00e676" }, - ".cm-number": { color: "#ffab00" }, - ".cm-bool": { color: "#d500f9" }, - ".cm-null": { color: "#78909c" }, - ".cm-comment": { color: "#455a64" }, - ".cm-meta": { color: "#78909c" }, - ".cm-punctuation": { color: "#546e7a" }, - ".cm-atom": { color: "#d500f9" }, - ".cm-keyword": { color: "#00e5ff" }, - ".cm-typeName": { color: "#ffab00" }, - ".cm-definition": { color: "#00e5ff" }, -}); + '.cm-propertyName': { color: '#00e5ff' }, + '.cm-string': { color: '#00e676' }, + '.cm-number': { color: '#ffab00' }, + '.cm-bool': { color: '#d500f9' }, + '.cm-null': { color: '#78909c' }, + '.cm-comment': { color: '#455a64' }, + '.cm-meta': { color: '#78909c' }, + '.cm-punctuation': { color: '#546e7a' }, + '.cm-atom': { color: '#d500f9' }, + '.cm-keyword': { color: '#00e5ff' }, + '.cm-typeName': { color: '#ffab00' }, + '.cm-definition': { color: '#00e5ff' }, +}) -export const CodeMirrorEditor: React.FC = ({ - value, - onChange, -}) => { - const containerRef = useRef(null); - const viewRef = useRef(null); - const onChangeRef = useRef(onChange); - onChangeRef.current = onChange; +export const CodeMirrorEditor: React.FC = ({ value, onChange }) => { + const containerRef = useRef(null) + const viewRef = useRef(null) + const onChangeRef = useRef(onChange) + onChangeRef.current = onChange // Create editor on mount useEffect(() => { - if (!containerRef.current) return; + if (!containerRef.current) return const updateListener = EditorView.updateListener.of((update) => { if (update.docChanged) { - onChangeRef.current(update.state.doc.toString()); + onChangeRef.current(update.state.doc.toString()) } - }); + }) const state = EditorState.create({ doc: value, @@ -142,40 +150,35 @@ export const CodeMirrorEditor: React.FC = ({ lintGutter(), yaml(), syntaxHighlighting(highlightColors), - keymap.of([ - ...defaultKeymap, - ...historyKeymap, - ...searchKeymap, - indentWithTab, - ]), + keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap, indentWithTab]), theme, syntaxColors, updateListener, EditorView.lineWrapping, EditorState.tabSize.of(2), ], - }); + }) const view = new EditorView({ state, parent: containerRef.current, - }); + }) - viewRef.current = view; + viewRef.current = view return () => { - view.destroy(); - viewRef.current = null; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + view.destroy() + viewRef.current = null + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) // Sync external value changes (e.g. loading a new file) useEffect(() => { - const view = viewRef.current; - if (!view) return; + const view = viewRef.current + if (!view) return - const currentContent = view.state.doc.toString(); + const currentContent = view.state.doc.toString() if (value !== currentContent) { view.dispatch({ changes: { @@ -183,17 +186,17 @@ export const CodeMirrorEditor: React.FC = ({ to: currentContent.length, insert: value, }, - }); + }) } - }, [value]); + }, [value]) return (
- ); -}; + ) +} diff --git a/apps/web/src/components/PreviewPane.tsx b/apps/web/src/components/PreviewPane.tsx index 0e46f5a..1da019f 100644 --- a/apps/web/src/components/PreviewPane.tsx +++ b/apps/web/src/components/PreviewPane.tsx @@ -1,68 +1,63 @@ -import React, { useRef, useState, useCallback } from "react"; -import html2canvas from "html2canvas"; -import type { PositionedGraph, ValidationError, Device } from "@homelab-stackdoc/core"; -import { TopologyCanvas } from "@homelab-stackdoc/renderer"; -import { SharePanel } from "./SharePanel"; +import html2canvas from 'html2canvas' +import React, { useRef, useState, useCallback } from 'react' +import { SharePanel } from './SharePanel' +import { TopologyCanvas } from '@homelab-stackdoc/renderer' +import type { PositionedGraph, ValidationError, Device, Connection } from '@homelab-stackdoc/core' interface PreviewPaneProps { - graph: PositionedGraph | null; - errors: ValidationError[]; - expanded: Set; - onToggleExpand: (id: string) => void; - deviceMap: Map; - yaml: string; + graph: PositionedGraph | null + errors: ValidationError[] + deviceMap: Map + connections: Connection[] + yaml: string } export const PreviewPane: React.FC = ({ graph, errors, - expanded, - onToggleExpand, deviceMap, + connections, yaml, }) => { - const captureRef = useRef(null); - const [isExporting, setIsExporting] = useState(false); + const captureRef = useRef(null) + const [isExporting, setIsExporting] = useState(false) const handleExportPng = useCallback(async () => { - if (!captureRef.current || !graph) return; - setIsExporting(true); - + if (!captureRef.current || !graph) return + setIsExporting(true) try { const canvas = await html2canvas(captureRef.current, { - backgroundColor: "#080f1e", + backgroundColor: '#080f1e', scale: 2, useCORS: true, logging: false, - // Capture the full container width: captureRef.current.offsetWidth, height: captureRef.current.offsetHeight, - }); - - const link = document.createElement("a"); - link.download = `homelab-topology-${Date.now()}.png`; - link.href = canvas.toDataURL("image/png"); - link.click(); + }) + const link = document.createElement('a') + link.download = `homelab-topology-${Date.now()}.png` + link.href = canvas.toDataURL('image/png') + link.click() } catch (err) { - console.error("PNG export failed:", err); + console.error('PNG export failed:', err) } finally { - setIsExporting(false); + setIsExporting(false) } - }, [graph]); + }, [graph]) - if (errors.some((e) => e.severity === "error") || !graph) { + if (errors.some((e) => e.severity === 'error') || !graph) { return (
@@ -71,24 +66,15 @@ export const PreviewPane: React.FC = ({
Fix the YAML errors to see the topology preview.
- ); + ) } return ( -
-
- +
+
+
- +
- ); -}; + ) +} diff --git a/apps/web/src/components/SharePanel.tsx b/apps/web/src/components/SharePanel.tsx index 110067f..26c0ee3 100644 --- a/apps/web/src/components/SharePanel.tsx +++ b/apps/web/src/components/SharePanel.tsx @@ -1,34 +1,34 @@ -import React, { useState } from "react"; +import React, { useState } from 'react' const colors = { - background: "rgba(12, 21, 39, 0.95)", - border: "rgba(0, 229, 255, 0.12)", - borderHover: "rgba(0, 229, 255, 0.35)", - primary: "#00e5ff", - green: "#00e676", - textPrimary: "#e0f7fa", - textSecondary: "#78909c", - textMuted: "#455a64", -}; + background: 'rgba(12, 21, 39, 0.95)', + border: 'rgba(0, 229, 255, 0.12)', + borderHover: 'rgba(0, 229, 255, 0.35)', + primary: '#00e5ff', + green: '#00e676', + textPrimary: '#e0f7fa', + textSecondary: '#78909c', + textMuted: '#455a64', +} const fonts = { mono: "'JetBrains Mono', 'Fira Code', 'SF Mono', monospace", -}; +} interface SharePanelProps { - yaml: string; - onExportPng: () => void; - isExporting: boolean; + yaml: string + onExportPng: () => void + isExporting: boolean } const ActionButton: React.FC<{ - onClick: () => void; - icon: React.ReactNode; - label: string; - sublabel?: string; - disabled?: boolean; + onClick: () => void + icon: React.ReactNode + label: string + sublabel?: string + disabled?: boolean }> = ({ onClick, icon, label, sublabel, disabled }) => { - const [hovered, setHovered] = useState(false); + const [hovered, setHovered] = useState(false) return ( - ); -}; + ) +} -export const SharePanel: React.FC = ({ - yaml, - onExportPng, - isExporting, -}) => { - const [open, setOpen] = useState(false); - const [copied, setCopied] = useState(false); +export const SharePanel: React.FC = ({ yaml, onExportPng, isExporting }) => { + const [open, setOpen] = useState(false) + const [copied, setCopied] = useState(false) const handleCopyYaml = async () => { try { - await navigator.clipboard.writeText(yaml); - setCopied(true); - setTimeout(() => setCopied(false), 2000); + await navigator.clipboard.writeText(yaml) + setCopied(true) + setTimeout(() => setCopied(false), 2000) } catch { // Fallback - const textarea = document.createElement("textarea"); - textarea.value = yaml; - document.body.appendChild(textarea); - textarea.select(); - document.execCommand("copy"); - document.body.removeChild(textarea); - setCopied(true); - setTimeout(() => setCopied(false), 2000); + const textarea = document.createElement('textarea') + textarea.value = yaml + document.body.appendChild(textarea) + textarea.select() + document.execCommand('copy') + document.body.removeChild(textarea) + setCopied(true) + setTimeout(() => setCopied(false), 2000) } - }; + } const handleDownloadYaml = () => { - const blob = new Blob([yaml], { type: "text/yaml" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "homelab-topology.yaml"; - a.click(); - URL.revokeObjectURL(url); - }; + const blob = new Blob([yaml], { type: 'text/yaml' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = 'homelab-topology.yaml' + a.click() + URL.revokeObjectURL(url) + } return (
= ({
- ); -}; + ) +} diff --git a/apps/web/src/components/YamlEditor.tsx b/apps/web/src/components/YamlEditor.tsx index a88913c..42e4300 100644 --- a/apps/web/src/components/YamlEditor.tsx +++ b/apps/web/src/components/YamlEditor.tsx @@ -1,47 +1,42 @@ -import React from "react"; -import { CodeMirrorEditor } from "./CodeMirrorEditor"; -import type { ValidationError } from "@homelab-stackdoc/core"; +import React from 'react' +import { CodeMirrorEditor } from './CodeMirrorEditor' +import type { ValidationError } from '@homelab-stackdoc/core' interface YamlEditorProps { - value: string; - onChange: (value: string) => void; - errors: ValidationError[]; + value: string + onChange: (value: string) => void + errors: ValidationError[] } const colors = { - border: "rgba(0, 229, 255, 0.12)", - red: "#ff1744", - amber: "#ffab00", - green: "#00e676", - textSecondary: "#78909c", -}; + border: 'rgba(0, 229, 255, 0.12)', + red: '#ff1744', + amber: '#ffab00', + green: '#00e676', + textSecondary: '#78909c', +} -export const YamlEditor: React.FC = ({ - value, - onChange, - errors, -}) => { - const errorCount = errors.filter((e) => e.severity === "error").length; - const warningCount = errors.filter((e) => e.severity === "warning").length; +export const YamlEditor: React.FC = ({ value, onChange, errors }) => { + const errorCount = errors.filter((e) => e.severity === 'error').length + const warningCount = errors.filter((e) => e.severity === 'warning').length return (
{/* Toolbar */}
= ({ > homelab.yaml -
+
{errorCount > 0 && ( - {errorCount} error{errorCount !== 1 ? "s" : ""} + {errorCount} error{errorCount !== 1 ? 's' : ''} )} {warningCount > 0 && ( - {warningCount} warning{warningCount !== 1 ? "s" : ""} + {warningCount} warning{warningCount !== 1 ? 's' : ''} )} {errorCount === 0 && warningCount === 0 && ( @@ -83,9 +78,9 @@ export const YamlEditor: React.FC = ({
= ({
- - {err.path || "root"} - {" "} - {err.message} + {err.path || 'root'} {err.message}
))}
)}
- ); -}; + ) +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 1c711a7..bdf4d94 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,9 +1,9 @@ -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import { App } from "./App"; +import { createRoot } from 'react-dom/client' +import { StrictMode } from 'react' +import { App } from './App' -createRoot(document.getElementById("root")!).render( +createRoot(document.getElementById('root')!).render( , -); +) diff --git a/apps/web/src/sample.yaml b/apps/web/src/sample.yaml new file mode 100644 index 0000000..d3ea8ef --- /dev/null +++ b/apps/web/src/sample.yaml @@ -0,0 +1,366 @@ +meta: + title: Home Network Topology + subtitle: Infrastructure · 2025 + tags: + - 1G FIBER + - PROXMOX 8.2 + - TAILSCALE ACTIVE + +networks: + - id: lan + name: LAN + subnet: 10.1.0.0/24 + - id: home-wifi + name: Home WiFi + subnet: 10.2.0.0/24 + dhcp: + start: 10.2.0.100 + end: 10.2.0.200 + - id: iot + name: IoT VLAN + subnet: 10.3.0.0/24 + vlan: 30 + +groups: + - id: network-edge + name: Network Edge + style: dashed + color: "#00e5ff" + - id: proxmox-cluster + name: Proxmox Cluster + style: solid + color: "#ffab00" + - id: wireless + name: Wireless Access + style: dashed + color: "#00e5ff" + - id: client-devices + name: Client Devices + style: dashed + color: "#d500f9" + - id: iot-devices + name: IoT / Security + style: dashed + color: "#ff1744" + +devices: + - id: isp-modem + name: ISP Modem + type: modem + ip: 192.168.0.1 + network: lan + group: network-edge + tags: + - BRIDGE MODE + interfaces: + ethernet: + count: 2 + speed: 1G + metadata: + download: "1000 Mb/s" + upload: "1000 Mb/s" + + - id: pfsense + name: netwatch + type: firewall + ip: 10.0.0.1/24 + network: lan + group: network-edge + tags: + - PFSENSE + - N150 + specs: + ram: 16GB + interfaces: + ethernet: + count: 4 + speed: 2.5G + + - id: main-switch + name: tp-link-switch + type: switch + ip: 10.1.0.1/24 + network: lan + tags: + - 2.5 GBIT + - 8 PORT + interfaces: + ethernet: + count: 8 + speed: 2.5G + + - id: home-router + name: archer + type: router + ip: 10.2.0.1/24 + network: home-wifi + group: wireless + tags: + - HOME + - GUEST + interfaces: + ethernet: + count: 4 + speed: 1G + wifi: + bands: + - 2.4GHz + - 5GHz + + - id: iot-router + name: tp-link-router + type: router + ip: 10.3.0.1/23 + network: iot + group: wireless + tags: + - IOT + - AP MODE + interfaces: + ethernet: + count: 4 + speed: 1G + wifi: + bands: + - 2.4GHz + - 5GHz + + - id: proxmox-host + name: beetle + type: hypervisor + ip: 10.1.10.1 + network: lan + group: proxmox-cluster + tags: + - PROXMOX 8.2 + specs: + cpu: i5 + ram: 64GB + storage: 10 TB + gpu: INTEL HD + interfaces: + ethernet: + count: 2 + speed: 2.5G + children: + - id: truenas + name: TrueNAS + type: vm + ip: 10.1.10.100 + tags: + - NAS + services: + - name: SMB + port: 445 + runtime: native + - name: NFS + port: 2049 + runtime: native + - id: jellyfin + name: Jellyfin + type: container + ip: 10.1.10.101 + tags: + - MEDIA + services: + - name: Jellyfin + port: 8096 + runtime: docker + - name: Sonarr + port: 8989 + runtime: docker + - name: Radarr + port: 7878 + runtime: docker + - id: adguard + name: AdGuard + type: container + ip: 10.1.10.102 + tags: + - DNS + services: + - name: AdGuard Home + port: 3000 + runtime: native + + - id: server-shelby + name: shelby + type: server + ip: 10.1.20.1 + network: lan + group: proxmox-cluster + specs: + cpu: i5 + ram: 64GB + storage: 256GB + gpu: NVIDIA GTX1650 + interfaces: + ethernet: + count: 1 + speed: 1G + + - id: server-mustang + name: mustang + type: server + ip: 10.1.30.1 + network: lan + group: proxmox-cluster + specs: + cpu: i7 + ram: 64GB + storage: 2TB + gpu: RADEON + interfaces: + ethernet: + count: 1 + speed: 1G + + - id: tailscale + name: Tailscale + type: vpn + tags: + - MESH VPN + - SUBNET ROUTER + + - id: workstation + name: workstation + type: desktop + ip: 10.2.10.1 + network: home-wifi + group: client-devices + interfaces: + wifi: + bands: + - 5GHz + + - id: phone + name: cellphone + type: phone + ip: 10.2.10.2 + network: home-wifi + group: client-devices + interfaces: + wifi: + bands: + - 5GHz + + - id: maverick + name: maverick + type: laptop + ip: 10.2.10.3 + network: home-wifi + group: client-devices + tags: + - PRIMARY + interfaces: + ethernet: + count: 1 + speed: 1G + wifi: + bands: + - 5GHz + + - id: alarm + name: alarm + type: iot + ip: 10.3.10.1 + network: iot + group: iot-devices + tags: + - SECURITY + interfaces: + wifi: + bands: + - 2.4GHz + + - id: dvr-cameras + name: dvr-cameras + type: camera + ip: 10.3.10.2 + network: iot + group: iot-devices + specs: + storage: 4 TB + tags: + - 8 CAMERAS + interfaces: + ethernet: + count: 1 + speed: 100M + + - id: room-tv + name: room-tv + type: tv + ip: 10.2.10.9 + network: home-wifi + group: client-devices + interfaces: + wifi: + bands: + - 5GHz + +connections: + - from: isp-modem + to: pfsense + type: ethernet + speed: 1G + + - from: pfsense + to: main-switch + type: ethernet + speed: 2.5G + + - from: main-switch + to: home-router + type: ethernet + speed: 1G + + - from: main-switch + to: iot-router + type: ethernet + speed: 1G + + - from: main-switch + to: proxmox-host + type: ethernet + speed: 2.5G + + - from: main-switch + to: server-shelby + type: ethernet + speed: 1G + + - from: main-switch + to: server-mustang + type: ethernet + speed: 1G + + - from: home-router + to: workstation + type: wifi + + - from: home-router + to: phone + type: wifi + + - from: home-router + to: maverick + type: wifi + + - from: home-router + to: room-tv + type: wifi + + - from: iot-router + to: alarm + type: wifi + + - from: iot-router + to: dvr-cameras + type: ethernet + speed: 100M + + - from: maverick + to: tailscale + type: vpn + label: tailscale diff --git a/apps/web/src/sampleYaml.ts b/apps/web/src/sampleYaml.ts index c0a546f..29ae609 100644 --- a/apps/web/src/sampleYaml.ts +++ b/apps/web/src/sampleYaml.ts @@ -52,6 +52,10 @@ devices: group: network-edge tags: - BRIDGE MODE + interfaces: + ethernet: + count: 2 + speed: 1G metadata: download: "1000 Mb/s" upload: "1000 Mb/s" @@ -67,6 +71,10 @@ devices: - N150 specs: ram: 16GB + interfaces: + ethernet: + count: 4 + speed: 2.5G - id: main-switch name: tp-link-switch @@ -76,6 +84,10 @@ devices: tags: - 2.5 GBIT - 8 PORT + interfaces: + ethernet: + count: 8 + speed: 2.5G - id: home-router name: archer @@ -86,6 +98,14 @@ devices: tags: - HOME - GUEST + interfaces: + ethernet: + count: 4 + speed: 1G + wifi: + bands: + - 2.4GHz + - 5GHz - id: iot-router name: tp-link-router @@ -96,6 +116,14 @@ devices: tags: - IOT - AP MODE + interfaces: + ethernet: + count: 4 + speed: 1G + wifi: + bands: + - 2.4GHz + - 5GHz - id: proxmox-host name: beetle @@ -110,6 +138,10 @@ devices: ram: 64GB storage: 10 TB gpu: INTEL HD + interfaces: + ethernet: + count: 2 + speed: 2.5G children: - id: truenas name: TrueNAS @@ -162,6 +194,10 @@ devices: ram: 64GB storage: 256GB gpu: NVIDIA GTX1650 + interfaces: + ethernet: + count: 1 + speed: 1G - id: server-mustang name: mustang @@ -174,6 +210,10 @@ devices: ram: 64GB storage: 2TB gpu: RADEON + interfaces: + ethernet: + count: 1 + speed: 1G - id: tailscale name: Tailscale @@ -188,6 +228,10 @@ devices: ip: 10.2.10.1 network: home-wifi group: client-devices + interfaces: + wifi: + bands: + - 5GHz - id: phone name: cellphone @@ -195,6 +239,10 @@ devices: ip: 10.2.10.2 network: home-wifi group: client-devices + interfaces: + wifi: + bands: + - 5GHz - id: maverick name: maverick @@ -204,6 +252,13 @@ devices: group: client-devices tags: - PRIMARY + interfaces: + ethernet: + count: 1 + speed: 1G + wifi: + bands: + - 5GHz - id: alarm name: alarm @@ -213,6 +268,10 @@ devices: group: iot-devices tags: - SECURITY + interfaces: + wifi: + bands: + - 2.4GHz - id: dvr-cameras name: dvr-cameras @@ -224,6 +283,10 @@ devices: storage: 4 TB tags: - 8 CAMERAS + interfaces: + ethernet: + count: 1 + speed: 100M - id: room-tv name: room-tv @@ -231,6 +294,10 @@ devices: ip: 10.2.10.9 network: home-wifi group: client-devices + interfaces: + wifi: + bands: + - 5GHz connections: - from: isp-modem @@ -296,4 +363,4 @@ connections: - from: maverick to: tailscale type: vpn - label: tailscale`; + label: tailscale` diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts new file mode 100644 index 0000000..3a94501 --- /dev/null +++ b/apps/web/src/vite-env.d.ts @@ -0,0 +1,6 @@ +/// + +declare module '*.yaml' { + const content: string + export default content +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index fd784bf..0b8ebf2 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,13 +1,13 @@ -import path from "path"; -import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; +import path from 'path' +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], resolve: { alias: { - "@homelab-stackdoc/core": path.resolve(__dirname, "../../packages/core/src"), - "@homelab-stackdoc/renderer": path.resolve(__dirname, "../../packages/renderer/src"), + '@homelab-stackdoc/core': path.resolve(__dirname, '../../packages/core/src'), + '@homelab-stackdoc/renderer': path.resolve(__dirname, '../../packages/renderer/src'), }, }, build: { @@ -15,19 +15,19 @@ export default defineConfig({ output: { manualChunks: { codemirror: [ - "codemirror", - "@codemirror/state", - "@codemirror/view", - "@codemirror/language", - "@codemirror/lang-yaml", - "@codemirror/commands", - "@codemirror/search", - "@codemirror/autocomplete", - "@codemirror/lint", + 'codemirror', + '@codemirror/state', + '@codemirror/view', + '@codemirror/language', + '@codemirror/lang-yaml', + '@codemirror/commands', + '@codemirror/search', + '@codemirror/autocomplete', + '@codemirror/lint', ], - vendor: ["react", "react-dom", "html2canvas"], + vendor: ['react', 'react-dom', 'html2canvas'], }, }, }, }, -}); +}) diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..37a0f31 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,35 @@ +import eslint from '@eslint/js' +import tseslint from 'typescript-eslint' +import reactPlugin from 'eslint-plugin-react' +import reactHooksPlugin from 'eslint-plugin-react-hooks' +import prettierConfig from 'eslint-config-prettier' + +export default [ + eslint.configs.recommended, + ...tseslint.configs.recommended, + prettierConfig, + { + files: ['**/*.{ts,tsx}'], + plugins: { + react: reactPlugin, + 'react-hooks': reactHooksPlugin, + }, + settings: { + react: { version: 'detect' }, + }, + rules: { + quotes: ['error', 'single', { avoidEscape: true }], + semi: ['error', 'never'], + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + 'react/prop-types': 'off', + 'react/display-name': 'off', + 'react/react-in-jsx-scope': 'off', + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn', + }, + }, + { + ignores: ['**/dist/**', '**/node_modules/**', '*.config.*'], + }, +] diff --git a/package.json b/package.json index c3b4d25..0b0517f 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,36 @@ "apps/*" ], "scripts": { - "dev": "cd apps/web && vite", "build": "cd apps/web && vite build", + "dev": "cd apps/web && vite", + "format": "prettier --write \"**/*.{ts,tsx,json,css}\"", + "format:check": "prettier --check \"**/*.{ts,tsx,json,css}\"", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "prepare": "husky", "typecheck": "tsc --build" }, + "lint-staged": { + "*.{ts,tsx}": [ + "eslint --fix", + "prettier --write" + ], + "*.{json,css}": [ + "prettier --write" + ] + }, "devDependencies": { - "typescript": "^5.5.0" + "@eslint/js": "^10.0.1", + "@typescript-eslint/eslint-plugin": "^8.57.2", + "@typescript-eslint/parser": "^8.57.2", + "eslint": "^10.1.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.0.1", + "husky": "^9.1.7", + "lint-staged": "^16.4.0", + "prettier": "^3.8.1", + "typescript": "^5.5.0", + "typescript-eslint": "^8.57.2" } } diff --git a/packages/core/__tests__/fixtures.ts b/packages/core/__tests__/fixtures.ts index 4752bb4..0ded806 100644 --- a/packages/core/__tests__/fixtures.ts +++ b/packages/core/__tests__/fixtures.ts @@ -1,4 +1,4 @@ -import type { HomelabDocument, Device, Connection } from "../src/types"; +import type { HomelabDocument, Device, Connection } from '../src/types' // ─── YAML Strings ──────────────────────────────────────────────── @@ -13,7 +13,7 @@ devices: type: router connections: [] -`; +` /** Full-featured YAML exercising every schema section. */ export const FULL_YAML = ` @@ -88,7 +88,7 @@ connections: - from: firewall to: laptop type: wifi -`; +` /** YAML with syntax error (bad indentation). */ export const INVALID_SYNTAX_YAML = ` @@ -97,13 +97,13 @@ meta: devices: - id: x name: bad indent -`; +` /** YAML that parses to a scalar, not a mapping. */ -export const SCALAR_YAML = `just a plain string`; +export const SCALAR_YAML = 'just a plain string' /** YAML that parses to null. */ -export const EMPTY_YAML = ``; +export const EMPTY_YAML = '' /** YAML with duplicate device IDs. */ export const DUPLICATE_IDS_YAML = ` @@ -119,7 +119,7 @@ devices: type: router connections: [] -`; +` /** YAML with connections referencing non-existent devices. */ export const DANGLING_REF_YAML = ` @@ -134,7 +134,7 @@ devices: connections: - from: router to: ghost-device -`; +` /** YAML with devices referencing undefined networks and groups. */ export const UNDEFINED_REFS_YAML = ` @@ -149,67 +149,59 @@ devices: group: nonexistent-group connections: [] -`; +` // ─── Document Builders ─────────────────────────────────────────── /** Builds a minimal valid HomelabDocument. Override any field via the partial. */ -export function buildDoc( - overrides: Partial = {}, -): HomelabDocument { +export function buildDoc(overrides: Partial = {}): HomelabDocument { return { - meta: { title: "Test Lab" }, - devices: [{ id: "router", name: "Main Router", type: "router" }], + meta: { title: 'Test Lab' }, + devices: [{ id: 'router', name: 'Main Router', type: 'router' }], connections: [], ...overrides, - }; + } } /** Builds a device with sensible defaults. */ export function buildDevice(overrides: Partial = {}): Device { return { - id: "device-1", - name: "Device 1", - type: "server", + id: 'device-1', + name: 'Device 1', + type: 'server', ...overrides, - }; + } } /** Builds a connection with sensible defaults. */ -export function buildConnection( - overrides: Partial = {}, -): Connection { +export function buildConnection(overrides: Partial = {}): Connection { return { - from: "a", - to: "b", + from: 'a', + to: 'b', ...overrides, - }; + } } /** A document with a parent device that has children — useful for layout/expand tests. */ -export function buildDocWithChildren( - expanded: boolean = false, -): HomelabDocument { +export function buildDocWithChildren(_expanded: boolean = false): HomelabDocument { return { - meta: { title: "Nested Lab" }, + meta: { title: 'Nested Lab' }, devices: [ { - id: "hypervisor", - name: "Proxmox", - type: "hypervisor", + id: 'hypervisor', + name: 'Proxmox', + type: 'hypervisor', children: [ - { id: "vm-1", name: "VM 1", type: "vm" }, - { id: "vm-2", name: "VM 2", type: "vm" }, + { id: 'vm-1', name: 'VM 1', type: 'vm' }, + { id: 'vm-2', name: 'VM 2', type: 'vm' }, ], }, { - id: "switch", - name: "Main Switch", - type: "switch", + id: 'switch', + name: 'Main Switch', + type: 'switch', }, ], - connections: [ - { from: "hypervisor", to: "switch" }, - ], - }; + connections: [{ from: 'hypervisor', to: 'switch' }], + } } diff --git a/packages/core/__tests__/layout.test.ts b/packages/core/__tests__/layout.test.ts index 98358ba..94fe6fc 100644 --- a/packages/core/__tests__/layout.test.ts +++ b/packages/core/__tests__/layout.test.ts @@ -1,452 +1,478 @@ -import { describe, it, expect } from "vitest"; -import { buildDoc, buildDevice, buildConnection, buildDocWithChildren } from "./fixtures"; -import { DEFAULT_LAYOUT_OPTIONS } from "../src/types"; -import { layout } from "../src/layout"; -import type { HomelabDocument, PositionedGraph } from "../src/types"; +import { describe, it, expect } from 'vitest' +import { layout } from '../src/layout' +import { DEFAULT_LAYOUT_OPTIONS } from '../src/types' +import type { HomelabDocument, PositionedGraph } from '../src/types' +import { buildDoc, buildDevice, buildConnection, buildDocWithChildren } from './fixtures' + +// ─── Helpers ────────────────────────────────────────────────────── /** Shortcut to find a positioned node by device id. */ function findNode(graph: PositionedGraph, id: string) { - return graph.nodes.find((n) => n.device.id === id); + return graph.nodes.find((n) => n.device.id === id) } /** Shortcut to find an edge by its from→to pair. */ function findEdge(graph: PositionedGraph, from: string, to: string) { - return graph.edges.find( - (e) => e.fromNodeId === from && e.toNodeId === to, - ); + return graph.edges.find((e) => e.fromNodeId === from && e.toNodeId === to) } // ─── Basic positioning ──────────────────────────────────────────── -describe("layout › basic positioning", () => { - it("places a single device as one node with default dimensions", () => { - const doc = buildDoc(); +describe('layout › basic positioning', () => { + it('places a single device as one node with default dimensions', () => { + const doc = buildDoc() - const graph = layout(doc); + const graph = layout(doc) - expect(graph.nodes).toHaveLength(1); - const node = graph.nodes[0]; - expect(node.device.id).toBe("router"); - expect(node.width).toBe(DEFAULT_LAYOUT_OPTIONS.nodeWidth); - expect(node.height).toBe(DEFAULT_LAYOUT_OPTIONS.nodeHeight); - expect(node.depth).toBe(0); - }); + expect(graph.nodes).toHaveLength(1) + const node = graph.nodes[0] + expect(node.device.id).toBe('router') + expect(node.width).toBe(DEFAULT_LAYOUT_OPTIONS.nodeWidth) + expect(node.height).toBe(DEFAULT_LAYOUT_OPTIONS.nodeHeight) + expect(node.depth).toBe(0) + }) - it("positions multiple unconnected devices in the same layer (depth 0)", () => { + it('positions multiple unconnected devices in the same layer (depth 0)', () => { const doc = buildDoc({ devices: [ - buildDevice({ id: "a", name: "A" }), - buildDevice({ id: "b", name: "B" }), - buildDevice({ id: "c", name: "C" }), + buildDevice({ id: 'a', name: 'A' }), + buildDevice({ id: 'b', name: 'B' }), + buildDevice({ id: 'c', name: 'C' }), ], - }); + }) - const graph = layout(doc); + const graph = layout(doc) - expect(graph.nodes).toHaveLength(3); + expect(graph.nodes).toHaveLength(3) // All nodes should share the same y and depth since there are no connections. - const ys = graph.nodes.map((n) => n.y); - expect(new Set(ys).size).toBe(1); + const ys = graph.nodes.map((n) => n.y) + expect(new Set(ys).size).toBe(1) - const depths = graph.nodes.map((n) => n.depth); - expect(depths).toEqual([0, 0, 0]); + const depths = graph.nodes.map((n) => n.depth) + expect(depths).toEqual([0, 0, 0]) // Nodes should be sorted left-to-right with increasing x. - const xs = graph.nodes.map((n) => n.x); - expect(xs[0]).toBeLessThan(xs[1]); - expect(xs[1]).toBeLessThan(xs[2]); - }); + const xs = graph.nodes.map((n) => n.x) + expect(xs[0]).toBeLessThan(xs[1]) + expect(xs[1]).toBeLessThan(xs[2]) + }) - it("layers connected devices at increasing depth", () => { + it('layers connected devices at increasing depth', () => { const doc = buildDoc({ devices: [ - buildDevice({ id: "a", name: "Root" }), - buildDevice({ id: "b", name: "Mid" }), - buildDevice({ id: "c", name: "Leaf" }), + buildDevice({ id: 'a', name: 'Root' }), + buildDevice({ id: 'b', name: 'Mid' }), + buildDevice({ id: 'c', name: 'Leaf' }), ], connections: [ - buildConnection({ from: "a", to: "b" }), - buildConnection({ from: "b", to: "c" }), + buildConnection({ from: 'a', to: 'b' }), + buildConnection({ from: 'b', to: 'c' }), ], - }); + }) - const graph = layout(doc); + const graph = layout(doc) - const a = findNode(graph, "a")!; - const b = findNode(graph, "b")!; - const c = findNode(graph, "c")!; + const a = findNode(graph, 'a')! + const b = findNode(graph, 'b')! + const c = findNode(graph, 'c')! - expect(a.depth).toBe(0); - expect(b.depth).toBe(1); - expect(c.depth).toBe(2); + expect(a.depth).toBe(0) + expect(b.depth).toBe(1) + expect(c.depth).toBe(2) // Deeper nodes must have a higher y coordinate (further down). - expect(a.y).toBeLessThan(b.y); - expect(b.y).toBeLessThan(c.y); - }); + expect(a.y).toBeLessThan(b.y) + expect(b.y).toBeLessThan(c.y) + }) - it("normalises all positions to positive coordinates", () => { + it('normalises all positions to positive coordinates', () => { const doc = buildDoc({ - devices: [ - buildDevice({ id: "a", name: "A" }), - buildDevice({ id: "b", name: "B" }), - ], - }); + devices: [buildDevice({ id: 'a', name: 'A' }), buildDevice({ id: 'b', name: 'B' })], + }) - const graph = layout(doc); + const graph = layout(doc) for (const node of graph.nodes) { - expect(node.x).toBeGreaterThanOrEqual(0); - expect(node.y).toBeGreaterThanOrEqual(0); + expect(node.x).toBeGreaterThanOrEqual(0) + expect(node.y).toBeGreaterThanOrEqual(0) } - }); -}); - -// ─── Expand / collapse ──────────────────────────────────────────── - -describe("layout › expand / collapse", () => { - it("excludes children from output when parent is collapsed (default)", () => { - const doc = buildDocWithChildren(); - const graph = layout(doc); - - const ids = graph.nodes.map((n) => n.device.id); - expect(ids).toContain("hypervisor"); - expect(ids).toContain("switch"); - expect(ids).not.toContain("vm-1"); - expect(ids).not.toContain("vm-2"); - }); - - it("includes children as positioned nodes when parent is expanded", () => { - const doc = buildDocWithChildren(); - const graph = layout(doc, { expanded: new Set(["hypervisor"]) }); - - const ids = graph.nodes.map((n) => n.device.id); - expect(ids).toContain("hypervisor"); - expect(ids).toContain("vm-1"); - expect(ids).toContain("vm-2"); - expect(ids).toContain("switch"); - }); - - it("positions expanded children below their parent", () => { - const doc = buildDocWithChildren(); - const graph = layout(doc, { expanded: new Set(["hypervisor"]) }); - - const parent = findNode(graph, "hypervisor")!; - const child1 = findNode(graph, "vm-1")!; - const child2 = findNode(graph, "vm-2")!; - - // Children must be below the parent row. - expect(child1.y).toBeGreaterThan(parent.y); - expect(child2.y).toBeGreaterThan(parent.y); - - // Children share the same y (same sub-row). - expect(child1.y).toBe(child2.y); - }); - - it("reroutes connections to collapsed children to the parent", () => { + }) + + it('gives all nodes uniform dimensions regardless of content', () => { + const doc = buildDoc({ + devices: [ + buildDevice({ id: 'bare', name: 'Bare' }), + { + id: 'loaded', + name: 'Loaded Server', + type: 'hypervisor', + children: [ + { id: 'vm-1', name: 'VM 1', type: 'vm' }, + { id: 'vm-2', name: 'VM 2', type: 'vm' }, + ], + services: [ + { name: 'nginx', port: 80 }, + { name: 'postgres', port: 5432 }, + ], + }, + ], + }) + + const graph = layout(doc) + + const widths = new Set(graph.nodes.map((n) => n.width)) + const heights = new Set(graph.nodes.map((n) => n.height)) + + expect(widths.size).toBe(1) + expect(heights.size).toBe(1) + expect(graph.nodes[0].width).toBe(DEFAULT_LAYOUT_OPTIONS.nodeWidth) + expect(graph.nodes[0].height).toBe(DEFAULT_LAYOUT_OPTIONS.nodeHeight) + }) +}) + +// ─── Connection rerouting ───────────────────────────────────────── +// Children are rendered inside parent cards, not as separate graph +// nodes. Any connection referencing a child is rerouted to its parent. + +describe('layout › connection rerouting', () => { + it('only positions top-level devices as graph nodes, not children', () => { + const doc = buildDocWithChildren() + const graph = layout(doc) + + const ids = graph.nodes.map((n) => n.device.id) + expect(ids).toContain('hypervisor') + expect(ids).toContain('switch') + expect(ids).not.toContain('vm-1') + expect(ids).not.toContain('vm-2') + }) + + it('reroutes connections targeting child devices to the parent', () => { const doc: HomelabDocument = { - meta: { title: "Reroute test" }, + meta: { title: 'Reroute test' }, devices: [ { - id: "parent", - name: "Parent", - type: "hypervisor", - children: [{ id: "child", name: "Child", type: "vm" }], + id: 'parent', + name: 'Parent', + type: 'hypervisor', + children: [{ id: 'child', name: 'Child', type: 'vm' }], }, - buildDevice({ id: "ext", name: "External", type: "switch" }), + buildDevice({ id: 'ext', name: 'External', type: 'switch' }), ], connections: [ - buildConnection({ from: "child", to: "ext" }), - buildConnection({ from: "parent", to: "ext" }), + buildConnection({ from: 'child', to: 'ext' }), + buildConnection({ from: 'parent', to: 'ext' }), ], - }; + } - // Collapsed — child is hidden, its connection should reroute to parent. - const graph = layout(doc); + const graph = layout(doc) // After rerouting and dedup, only one edge parent→ext should survive. - const edgePairs = graph.edges.map( - (e) => `${e.fromNodeId}→${e.toNodeId}`, - ); - expect(edgePairs).toContain("parent→ext"); - expect(edgePairs).not.toContain("child→ext"); + const edgePairs = graph.edges.map((e) => `${e.fromNodeId}→${e.toNodeId}`) + expect(edgePairs).toContain('parent→ext') + expect(edgePairs).not.toContain('child→ext') // Deduplication: shouldn't have two parent→ext edges. - const parentToExt = graph.edges.filter( - (e) => e.fromNodeId === "parent" && e.toNodeId === "ext", - ); - expect(parentToExt).toHaveLength(1); - }); + const parentToExt = graph.edges.filter((e) => e.fromNodeId === 'parent' && e.toNodeId === 'ext') + expect(parentToExt).toHaveLength(1) + }) - it("eliminates self-connections created by rerouting", () => { + it('eliminates self-connections created by rerouting', () => { const doc: HomelabDocument = { - meta: { title: "Self-loop test" }, + meta: { title: 'Self-loop test' }, devices: [ { - id: "host", - name: "Host", - type: "hypervisor", - children: [{ id: "vm", name: "VM", type: "vm" }], + id: 'host', + name: 'Host', + type: 'hypervisor', + children: [{ id: 'vm', name: 'VM', type: 'vm' }], }, ], // vm → host reroutes to host → host → eliminated. - connections: [buildConnection({ from: "vm", to: "host" })], - }; + connections: [buildConnection({ from: 'vm', to: 'host' })], + } - const graph = layout(doc); + const graph = layout(doc) - const selfEdges = graph.edges.filter( - (e) => e.fromNodeId === e.toNodeId, - ); - expect(selfEdges).toHaveLength(0); - }); + const selfEdges = graph.edges.filter((e) => e.fromNodeId === e.toNodeId) + expect(selfEdges).toHaveLength(0) + }) - it("deduplicates rerouted connections that produce the same pair", () => { + it('deduplicates rerouted connections that produce the same pair', () => { const doc: HomelabDocument = { - meta: { title: "Dedup test" }, + meta: { title: 'Dedup test' }, devices: [ { - id: "host", - name: "Host", - type: "hypervisor", + id: 'host', + name: 'Host', + type: 'hypervisor', children: [ - { id: "vm-a", name: "VM A", type: "vm" }, - { id: "vm-b", name: "VM B", type: "vm" }, + { id: 'vm-a', name: 'VM A', type: 'vm' }, + { id: 'vm-b', name: 'VM B', type: 'vm' }, ], }, - buildDevice({ id: "sw", name: "Switch", type: "switch" }), + buildDevice({ id: 'sw', name: 'Switch', type: 'switch' }), ], - // Both reroute to host→sw when collapsed. + // Both child connections reroute to host→sw. connections: [ - buildConnection({ from: "vm-a", to: "sw" }), - buildConnection({ from: "vm-b", to: "sw" }), - buildConnection({ from: "host", to: "sw" }), + buildConnection({ from: 'vm-a', to: 'sw' }), + buildConnection({ from: 'vm-b', to: 'sw' }), + buildConnection({ from: 'host', to: 'sw' }), ], - }; + } - const graph = layout(doc); + const graph = layout(doc) - const hostToSw = graph.edges.filter( - (e) => e.fromNodeId === "host" && e.toNodeId === "sw", - ); - expect(hostToSw).toHaveLength(1); - }); -}); + const hostToSw = graph.edges.filter((e) => e.fromNodeId === 'host' && e.toNodeId === 'sw') + expect(hostToSw).toHaveLength(1) + }) +}) // ─── Groups ─────────────────────────────────────────────────────── -describe("layout › groups", () => { - it("computes a bounding box around member devices", () => { +describe('layout › groups', () => { + it('computes a bounding box around member devices', () => { const doc = buildDoc({ - groups: [{ id: "rack", name: "Server Rack" }], + groups: [{ id: 'rack', name: 'Server Rack' }], devices: [ - buildDevice({ id: "a", name: "A", group: "rack" }), - buildDevice({ id: "b", name: "B", group: "rack" }), + buildDevice({ id: 'a', name: 'A', group: 'rack' }), + buildDevice({ id: 'b', name: 'B', group: 'rack' }), ], - }); + }) - const graph = layout(doc); + const graph = layout(doc) - expect(graph.groups).toHaveLength(1); - const group = graph.groups[0]; + expect(graph.groups).toHaveLength(1) + const group = graph.groups[0] - const a = findNode(graph, "a")!; - const b = findNode(graph, "b")!; + const a = findNode(graph, 'a')! + const b = findNode(graph, 'b')! - const pad = DEFAULT_LAYOUT_OPTIONS.groupPadding; + const pad = DEFAULT_LAYOUT_OPTIONS.groupPadding // Group box should contain both nodes with padding. - expect(group.x).toBeLessThanOrEqual(a.x - pad); - expect(group.y).toBeLessThanOrEqual(a.y - pad); - expect(group.x + group.width).toBeGreaterThanOrEqual( - b.x + b.width + pad, - ); - expect(group.y + group.height).toBeGreaterThanOrEqual( - b.y + b.height + pad, - ); - }); - - it("filters out groups with no member devices", () => { + expect(group.x).toBeLessThanOrEqual(a.x - pad) + expect(group.y).toBeLessThanOrEqual(a.y - pad) + expect(group.x + group.width).toBeGreaterThanOrEqual(b.x + b.width + pad) + expect(group.y + group.height).toBeGreaterThanOrEqual(b.y + b.height + pad) + }) + + it('filters out groups with no member devices', () => { const doc = buildDoc({ groups: [ - { id: "populated", name: "Has members" }, - { id: "empty", name: "No members" }, + { id: 'populated', name: 'Has members' }, + { id: 'empty', name: 'No members' }, ], - devices: [buildDevice({ id: "srv", name: "Server", group: "populated" })], - }); + devices: [buildDevice({ id: 'srv', name: 'Server', group: 'populated' })], + }) - const graph = layout(doc); + const graph = layout(doc) - expect(graph.groups).toHaveLength(1); - expect(graph.groups[0].group.id).toBe("populated"); - }); + expect(graph.groups).toHaveLength(1) + expect(graph.groups[0].group.id).toBe('populated') + }) - it("applies group padding correctly", () => { - const customPadding = 20; + it('applies group padding correctly', () => { + const customPadding = 20 const doc = buildDoc({ - groups: [{ id: "g", name: "G" }], - devices: [buildDevice({ id: "only", name: "Only", group: "g" })], - }); + groups: [{ id: 'g', name: 'G' }], + devices: [buildDevice({ id: 'only', name: 'Only', group: 'g' })], + }) - const graph = layout(doc, { groupPadding: customPadding }); + const graph = layout(doc, { groupPadding: customPadding }) - const node = findNode(graph, "only")!; - const group = graph.groups[0]; + const node = findNode(graph, 'only')! + const group = graph.groups[0] - // For a single-node group, width = nodeWidth + 2 * padding. - expect(group.width).toBe(node.width + customPadding * 2); - expect(group.height).toBe(node.height + customPadding * 2); - }); -}); + // For a single-node group: + // width = nodeWidth + 2 * padding (symmetric left/right) + // height = nodeHeight + padding + (padding + 16) (extra 16 at top for label) + expect(group.width).toBe(node.width + customPadding * 2) + expect(group.height).toBe(node.height + customPadding * 2 + 16) + }) + + it('adds extra horizontal spacing between nodes in different groups', () => { + // Two nodes in DIFFERENT groups — should get extra spacing. + const diffGroupDoc = buildDoc({ + groups: [ + { id: 'left-group', name: 'Left' }, + { id: 'right-group', name: 'Right' }, + ], + devices: [ + buildDevice({ id: 'a', name: 'A', group: 'left-group' }), + buildDevice({ id: 'b', name: 'B', group: 'right-group' }), + ], + }) + const diffLayout = layout(diffGroupDoc) + + // Two nodes in the SAME group — baseline spacing. + const sameGroupDoc = buildDoc({ + groups: [{ id: 'shared', name: 'Shared' }], + devices: [ + buildDevice({ id: 'a', name: 'A', group: 'shared' }), + buildDevice({ id: 'b', name: 'B', group: 'shared' }), + ], + }) + const sameLayout = layout(sameGroupDoc) + + const diffGap = + findNode(diffLayout, 'b')!.x - + (findNode(diffLayout, 'a')!.x + findNode(diffLayout, 'a')!.width) + const sameGap = + findNode(sameLayout, 'b')!.x - + (findNode(sameLayout, 'a')!.x + findNode(sameLayout, 'a')!.width) + + // Nodes in different groups should be spaced further apart than + // nodes sharing a group. + expect(diffGap).toBeGreaterThan(sameGap) + }) +}) // ─── Edges ──────────────────────────────────────────────────────── -describe("layout › edges", () => { - it("produces a 2-point (straight) edge for vertically aligned nodes", () => { +describe('layout › edges', () => { + it('produces a 2-point (straight) edge for vertically aligned nodes', () => { const doc = buildDoc({ devices: [ - buildDevice({ id: "top", name: "Top" }), - buildDevice({ id: "bottom", name: "Bottom" }), + buildDevice({ id: 'top', name: 'Top' }), + buildDevice({ id: 'bottom', name: 'Bottom' }), ], - connections: [buildConnection({ from: "top", to: "bottom" })], - }); + connections: [buildConnection({ from: 'top', to: 'bottom' })], + }) - const graph = layout(doc); + const graph = layout(doc) // A single connection between two nodes in a chain means they're in // separate layers, centred, so their x midpoints should align. - const edge = findEdge(graph, "top", "bottom"); - expect(edge).toBeDefined(); - expect(edge!.points).toHaveLength(2); - }); + const edge = findEdge(graph, 'top', 'bottom') + expect(edge).toBeDefined() + expect(edge!.points).toHaveLength(2) + }) - it("produces a 4-point (Manhattan) edge for horizontally offset nodes", () => { + it('produces a 4-point (Manhattan) edge for horizontally offset nodes', () => { // Three devices in layer 0, but only one in layer 1, plus a lateral // connection from a layer-0 sibling to the layer-1 device creates // an offset edge. const doc = buildDoc({ devices: [ - buildDevice({ id: "root", name: "Root" }), - buildDevice({ id: "left", name: "Left" }), - buildDevice({ id: "right", name: "Right" }), + buildDevice({ id: 'root', name: 'Root' }), + buildDevice({ id: 'left', name: 'Left' }), + buildDevice({ id: 'right', name: 'Right' }), ], connections: [ - buildConnection({ from: "root", to: "left" }), - buildConnection({ from: "root", to: "right" }), + buildConnection({ from: 'root', to: 'left' }), + buildConnection({ from: 'root', to: 'right' }), ], - }); + }) - const graph = layout(doc); + const graph = layout(doc) // root is depth 0, left and right are depth 1. If left and right are // side-by-side, at least one of the two edges should be offset (4 points). - const manhattanEdges = graph.edges.filter((e) => e.points.length === 4); - expect(manhattanEdges.length).toBeGreaterThanOrEqual(1); - }); + const manhattanEdges = graph.edges.filter((e) => e.points.length === 4) + expect(manhattanEdges.length).toBeGreaterThanOrEqual(1) + }) - it("filters out edges referencing non-existent nodes", () => { + it('filters out edges referencing non-existent nodes', () => { const doc = buildDoc({ - connections: [buildConnection({ from: "router", to: "ghost" })], - }); + connections: [buildConnection({ from: 'router', to: 'ghost' })], + }) // "ghost" has no device entry, so it won't be in the nodeMap. // The edge router should still produce a graph but drop the bad edge. - const graph = layout(doc); + const graph = layout(doc) - const ghostEdge = findEdge(graph, "router", "ghost"); - expect(ghostEdge).toBeUndefined(); - }); -}); + const ghostEdge = findEdge(graph, 'router', 'ghost') + expect(ghostEdge).toBeUndefined() + }) +}) // ─── Bounds ─────────────────────────────────────────────────────── -describe("layout › bounds", () => { - it("encompasses all nodes", () => { +describe('layout › bounds', () => { + it('encompasses all nodes', () => { const doc = buildDoc({ - devices: [ - buildDevice({ id: "a", name: "A" }), - buildDevice({ id: "b", name: "B" }), - ], - }); + devices: [buildDevice({ id: 'a', name: 'A' }), buildDevice({ id: 'b', name: 'B' })], + }) - const graph = layout(doc); + const graph = layout(doc) for (const node of graph.nodes) { - expect(node.x + node.width).toBeLessThanOrEqual(graph.bounds.width); - expect(node.y + node.height).toBeLessThanOrEqual(graph.bounds.height); + expect(node.x + node.width).toBeLessThanOrEqual(graph.bounds.width) + expect(node.y + node.height).toBeLessThanOrEqual(graph.bounds.height) } - }); + }) - it("encompasses all groups", () => { + it('encompasses all groups', () => { const doc = buildDoc({ - groups: [{ id: "g", name: "G" }], - devices: [buildDevice({ id: "srv", name: "S", group: "g" })], - }); + groups: [{ id: 'g', name: 'G' }], + devices: [buildDevice({ id: 'srv', name: 'S', group: 'g' })], + }) - const graph = layout(doc); + const graph = layout(doc) for (const group of graph.groups) { - expect(group.x + group.width).toBeLessThanOrEqual(graph.bounds.width); - expect(group.y + group.height).toBeLessThanOrEqual(graph.bounds.height); + expect(group.x + group.width).toBeLessThanOrEqual(graph.bounds.width) + expect(group.y + group.height).toBeLessThanOrEqual(graph.bounds.height) } - }); + }) - it("includes padding beyond outermost elements", () => { - const doc = buildDoc(); - const graph = layout(doc); - const node = graph.nodes[0]; + it('includes padding beyond outermost elements', () => { + const doc = buildDoc() + const graph = layout(doc) + const node = graph.nodes[0] // Bounds should extend past the node by at least groupPadding * 2. - const pad = DEFAULT_LAYOUT_OPTIONS.groupPadding * 2; - expect(graph.bounds.width).toBeGreaterThanOrEqual( - node.x + node.width + pad, - ); - expect(graph.bounds.height).toBeGreaterThanOrEqual( - node.y + node.height + pad, - ); - }); -}); + const pad = DEFAULT_LAYOUT_OPTIONS.groupPadding * 2 + expect(graph.bounds.width).toBeGreaterThanOrEqual(node.x + node.width + pad) + expect(graph.bounds.height).toBeGreaterThanOrEqual(node.y + node.height + pad) + }) +}) // ─── Options ────────────────────────────────────────────────────── -describe("layout › options", () => { - it("uses default options when none are provided", () => { - const doc = buildDoc(); - const graph = layout(doc); +describe('layout › options', () => { + it('uses default options when none are provided', () => { + const doc = buildDoc() + const graph = layout(doc) - const node = graph.nodes[0]; - expect(node.width).toBe(DEFAULT_LAYOUT_OPTIONS.nodeWidth); - expect(node.height).toBe(DEFAULT_LAYOUT_OPTIONS.nodeHeight); - }); + const node = graph.nodes[0] + expect(node.width).toBe(DEFAULT_LAYOUT_OPTIONS.nodeWidth) + expect(node.height).toBe(DEFAULT_LAYOUT_OPTIONS.nodeHeight) + }) - it("allows user options to override defaults", () => { - const doc = buildDoc(); - const graph = layout(doc, { nodeWidth: 300, nodeHeight: 150 }); + it('allows user options to override defaults', () => { + const doc = buildDoc() + const graph = layout(doc, { nodeWidth: 400, nodeHeight: 200 }) - const node = graph.nodes[0]; - expect(node.width).toBe(300); - expect(node.height).toBe(150); - }); + const node = graph.nodes[0] + expect(node.width).toBe(400) + expect(node.height).toBe(200) + }) + + it('respects custom spacing between nodes', () => { + const doc = buildDoc({ + devices: [buildDevice({ id: 'a', name: 'A' }), buildDevice({ id: 'b', name: 'B' })], + }) - it("respects a custom expanded set", () => { - const doc = buildDocWithChildren(); + const narrow = layout(doc, { horizontalSpacing: 20 }) + const wide = layout(doc, { horizontalSpacing: 200 }) - const collapsed = layout(doc); - const expanded = layout(doc, { expanded: new Set(["hypervisor"]) }); + const narrowGap = + findNode(narrow, 'b')!.x - (findNode(narrow, 'a')!.x + findNode(narrow, 'a')!.width) + const wideGap = findNode(wide, 'b')!.x - (findNode(wide, 'a')!.x + findNode(wide, 'a')!.width) - expect(collapsed.nodes.length).toBeLessThan(expanded.nodes.length); - expect(expanded.nodes.map((n) => n.device.id)).toContain("vm-1"); - }); -}); + expect(wideGap).toBeGreaterThan(narrowGap) + }) +}) // ─── Meta passthrough ───────────────────────────────────────────── -describe("layout › meta", () => { - it("passes the document meta through to the output graph", () => { - const doc = buildDoc({ meta: { title: "My Homelab" } }); - const graph = layout(doc); +describe('layout › meta', () => { + it('passes the document meta through to the output graph', () => { + const doc = buildDoc({ meta: { title: 'My Homelab' } }) + const graph = layout(doc) - expect(graph.meta.title).toBe("My Homelab"); - }); -}); + expect(graph.meta.title).toBe('My Homelab') + }) +}) diff --git a/packages/core/__tests__/parser.test.ts b/packages/core/__tests__/parser.test.ts index c95366a..82b644c 100644 --- a/packages/core/__tests__/parser.test.ts +++ b/packages/core/__tests__/parser.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect } from 'vitest' import { MINIMAL_YAML, FULL_YAML, @@ -6,135 +6,127 @@ import { EMPTY_YAML, DUPLICATE_IDS_YAML, DANGLING_REF_YAML, -} from "./fixtures"; -import { parse } from "../src/parser"; +} from './fixtures' +import { parse } from '../src/parser' -describe("parse", () => { +describe('parse', () => { // ── Successful parsing ────────────────────────────────────────── - describe("valid input", () => { - it("parses minimal YAML into a successful result", () => { - const result = parse(MINIMAL_YAML); - - expect(result.ok).toBe(true); - if (!result.ok) return; // type narrowing - - expect(result.document.meta.title).toBe("Test Lab"); - expect(result.document.devices).toHaveLength(1); - expect(result.document.devices[0].id).toBe("router"); - expect(result.document.connections).toEqual([]); - }); - - it("parses full YAML with all sections", () => { - const result = parse(FULL_YAML); - - expect(result.ok).toBe(true); - if (!result.ok) return; - - expect(result.document.meta.title).toBe("Full Homelab"); - expect(result.document.meta.subtitle).toBe("2025 Edition"); - expect(result.document.meta.tags).toEqual(["PROXMOX", "TAILSCALE"]); - expect(result.document.networks).toHaveLength(2); - expect(result.document.groups).toHaveLength(2); - expect(result.document.devices).toHaveLength(3); - expect(result.document.connections).toHaveLength(2); - }); - - it("preserves nested children on devices", () => { - const result = parse(FULL_YAML); - - expect(result.ok).toBe(true); - if (!result.ok) return; - - const hypervisor = result.document.devices.find( - (d) => d.id === "hypervisor", - ); - expect(hypervisor?.children).toHaveLength(2); - expect(hypervisor?.children?.[0].id).toBe("dns-vm"); - }); - - it("preserves services on nested devices", () => { - const result = parse(FULL_YAML); - - expect(result.ok).toBe(true); - if (!result.ok) return; - - const hypervisor = result.document.devices.find( - (d) => d.id === "hypervisor", - ); - const dnsVm = hypervisor?.children?.[0]; - expect(dnsVm?.services).toHaveLength(1); - expect(dnsVm?.services?.[0].name).toBe("Pi-hole"); - expect(dnsVm?.services?.[0].port).toBe(53); - expect(dnsVm?.services?.[0].runtime).toBe("docker"); - }); - - it("returns warnings for non-fatal issues without failing", () => { - const result = parse(DANGLING_REF_YAML); + describe('valid input', () => { + it('parses minimal YAML into a successful result', () => { + const result = parse(MINIMAL_YAML) + + expect(result.ok).toBe(true) + if (!result.ok) return // type narrowing + + expect(result.document.meta.title).toBe('Test Lab') + expect(result.document.devices).toHaveLength(1) + expect(result.document.devices[0].id).toBe('router') + expect(result.document.connections).toEqual([]) + }) + + it('parses full YAML with all sections', () => { + const result = parse(FULL_YAML) + + expect(result.ok).toBe(true) + if (!result.ok) return + + expect(result.document.meta.title).toBe('Full Homelab') + expect(result.document.meta.subtitle).toBe('2025 Edition') + expect(result.document.meta.tags).toEqual(['PROXMOX', 'TAILSCALE']) + expect(result.document.networks).toHaveLength(2) + expect(result.document.groups).toHaveLength(2) + expect(result.document.devices).toHaveLength(3) + expect(result.document.connections).toHaveLength(2) + }) + + it('preserves nested children on devices', () => { + const result = parse(FULL_YAML) + + expect(result.ok).toBe(true) + if (!result.ok) return + + const hypervisor = result.document.devices.find((d) => d.id === 'hypervisor') + expect(hypervisor?.children).toHaveLength(2) + expect(hypervisor?.children?.[0].id).toBe('dns-vm') + }) + + it('preserves services on nested devices', () => { + const result = parse(FULL_YAML) + + expect(result.ok).toBe(true) + if (!result.ok) return + + const hypervisor = result.document.devices.find((d) => d.id === 'hypervisor') + const dnsVm = hypervisor?.children?.[0] + expect(dnsVm?.services).toHaveLength(1) + expect(dnsVm?.services?.[0].name).toBe('Pi-hole') + expect(dnsVm?.services?.[0].port).toBe(53) + expect(dnsVm?.services?.[0].runtime).toBe('docker') + }) + + it('returns warnings for non-fatal issues without failing', () => { + const result = parse(DANGLING_REF_YAML) // The connection to "ghost-device" is an error, not a warning, // so this should fail validation. - expect(result.ok).toBe(false); - }); - }); + expect(result.ok).toBe(false) + }) + }) // ── YAML syntax failures ──────────────────────────────────────── - describe("YAML syntax errors", () => { - it("returns an error for unparseable YAML", () => { - const result = parse("meta: {title: [}"); + describe('YAML syntax errors', () => { + it('returns an error for unparseable YAML', () => { + const result = parse('meta: {title: [}') - expect(result.ok).toBe(false); - if (result.ok) return; + expect(result.ok).toBe(false) + if (result.ok) return - expect(result.errors).toHaveLength(1); - expect(result.errors[0].severity).toBe("error"); - }); + expect(result.errors).toHaveLength(1) + expect(result.errors[0].severity).toBe('error') + }) - it("returns an error when root is a scalar", () => { - const result = parse(SCALAR_YAML); + it('returns an error when root is a scalar', () => { + const result = parse(SCALAR_YAML) - expect(result.ok).toBe(false); - if (result.ok) return; + expect(result.ok).toBe(false) + if (result.ok) return - expect(result.errors[0].message).toBe( - "Document root must be a mapping.", - ); - }); + expect(result.errors[0].message).toBe('Document root must be a mapping.') + }) - it("returns an error for empty input", () => { - const result = parse(EMPTY_YAML); + it('returns an error for empty input', () => { + const result = parse(EMPTY_YAML) - expect(result.ok).toBe(false); - if (result.ok) return; + expect(result.ok).toBe(false) + if (result.ok) return - expect(result.errors[0].message).toBe( - "Document root must be a mapping.", - ); - }); - }); + expect(result.errors[0].message).toBe('Document root must be a mapping.') + }) + }) // ── Validation through parse ──────────────────────────────────── - describe("validation errors surfaced through parse", () => { - it("rejects a document with no devices", () => { + describe('validation errors surfaced through parse', () => { + it('rejects a document with no devices', () => { const yaml = ` meta: title: Empty devices: [] connections: [] -`; - const result = parse(yaml); +` + const result = parse(yaml) - expect(result.ok).toBe(false); - if (result.ok) return; + expect(result.ok).toBe(false) + if (result.ok) return - const deviceError = result.errors.find((e) => e.path === "devices"); - expect(deviceError).toBeDefined(); - expect(deviceError?.message).toContain("At least one device"); - }); + const deviceError = result.errors.find((e) => e.path === 'devices') + expect(deviceError).toBeDefined() + expect(deviceError?.message).toContain('At least one device') + }) - it("rejects a document with missing meta title", () => { + it('rejects a document with missing meta title', () => { const yaml = ` meta: {} devices: @@ -142,46 +134,42 @@ devices: name: X type: server connections: [] -`; - const result = parse(yaml); +` + const result = parse(yaml) - expect(result.ok).toBe(false); - if (result.ok) return; + expect(result.ok).toBe(false) + if (result.ok) return - const titleError = result.errors.find((e) => - e.path === "meta.title", - ); - expect(titleError).toBeDefined(); - }); + const titleError = result.errors.find((e) => e.path === 'meta.title') + expect(titleError).toBeDefined() + }) - it("rejects duplicate device IDs", () => { - const result = parse(DUPLICATE_IDS_YAML); + it('rejects duplicate device IDs', () => { + const result = parse(DUPLICATE_IDS_YAML) - expect(result.ok).toBe(false); - if (result.ok) return; + expect(result.ok).toBe(false) + if (result.ok) return - const dupeError = result.errors.find((e) => - e.message.includes("Duplicate"), - ); - expect(dupeError).toBeDefined(); - }); - }); + const dupeError = result.errors.find((e) => e.message.includes('Duplicate')) + expect(dupeError).toBeDefined() + }) + }) // ── Normalization behavior ────────────────────────────────────── - describe("normalization", () => { - it("defaults missing optional meta fields to undefined", () => { - const result = parse(MINIMAL_YAML); + describe('normalization', () => { + it('defaults missing optional meta fields to undefined', () => { + const result = parse(MINIMAL_YAML) - expect(result.ok).toBe(true); - if (!result.ok) return; + expect(result.ok).toBe(true) + if (!result.ok) return - expect(result.document.meta.subtitle).toBeUndefined(); - expect(result.document.meta.author).toBeUndefined(); - expect(result.document.meta.tags).toBeUndefined(); - }); + expect(result.document.meta.subtitle).toBeUndefined() + expect(result.document.meta.author).toBeUndefined() + expect(result.document.meta.tags).toBeUndefined() + }) - it("coerces device fields to strings", () => { + it('coerces device fields to strings', () => { const yaml = ` meta: title: Coercion Test @@ -190,32 +178,32 @@ devices: name: 456 type: server connections: [] -`; - const result = parse(yaml); +` + const result = parse(yaml) - expect(result.ok).toBe(true); - if (!result.ok) return; + expect(result.ok).toBe(true) + if (!result.ok) return // js-yaml parses bare 123 as a number; normalizeDevice coerces to string. - expect(result.document.devices[0].id).toBe("123"); - expect(result.document.devices[0].name).toBe("456"); - }); + expect(result.document.devices[0].id).toBe('123') + expect(result.document.devices[0].name).toBe('456') + }) - it("treats missing devices array as empty array", () => { + it('treats missing devices array as empty array', () => { const yaml = ` meta: title: No Devices connections: [] -`; - const result = parse(yaml); +` + const result = parse(yaml) // Should fail validation (no devices), but normalization itself // should not throw. - expect(result.ok).toBe(false); - if (result.ok) return; - - const deviceError = result.errors.find((e) => e.path === "devices"); - expect(deviceError).toBeDefined(); - }); - }); -}); + expect(result.ok).toBe(false) + if (result.ok) return + + const deviceError = result.errors.find((e) => e.path === 'devices') + expect(deviceError).toBeDefined() + }) + }) +}) diff --git a/packages/core/__tests__/validator.test.ts b/packages/core/__tests__/validator.test.ts index e6b7aee..ed874e7 100644 --- a/packages/core/__tests__/validator.test.ts +++ b/packages/core/__tests__/validator.test.ts @@ -1,374 +1,336 @@ -import { describe, it, expect } from "vitest"; -import { buildDoc, buildDevice, buildConnection } from "./fixtures"; -import { validate } from "../src/validator"; -import type { HomelabDocument, ValidationError } from "../src/types"; +import { describe, it, expect } from 'vitest' +import { buildDoc, buildDevice, buildConnection } from './fixtures' +import { validate } from '../src/validator' +import type { HomelabDocument, ValidationError } from '../src/types' -const errors = (list: ValidationError[]) => - list.filter((e) => e.severity === "error"); +const errors = (list: ValidationError[]) => list.filter((e) => e.severity === 'error') -const warnings = (list: ValidationError[]) => - list.filter((e) => e.severity === "warning"); +const warnings = (list: ValidationError[]) => list.filter((e) => e.severity === 'warning') // ─── Meta validation ────────────────────────────────────────────── -describe("validate › meta", () => { - it("returns an error when meta is missing entirely", () => { - const doc = buildDoc() as unknown as Record; - delete doc.meta; +describe('validate › meta', () => { + it('returns an error when meta is missing entirely', () => { + const doc = buildDoc() as unknown as Record + delete doc.meta - const result = validate(doc as unknown as HomelabDocument); + const result = validate(doc as unknown as HomelabDocument) - expect(errors(result)).toHaveLength(1); - expect(result[0].path).toBe("meta"); - expect(result[0].severity).toBe("error"); - }); + expect(errors(result)).toHaveLength(1) + expect(result[0].path).toBe('meta') + expect(result[0].severity).toBe('error') + }) - it("returns an error when meta.title is missing", () => { - const doc = buildDoc({ meta: {} as HomelabDocument["meta"] }); + it('returns an error when meta.title is missing', () => { + const doc = buildDoc({ meta: {} as HomelabDocument['meta'] }) - const result = validate(doc); + const result = validate(doc) - const titleErrors = errors(result).filter((e) => e.path === "meta.title"); - expect(titleErrors).toHaveLength(1); - }); + const titleErrors = errors(result).filter((e) => e.path === 'meta.title') + expect(titleErrors).toHaveLength(1) + }) - it("returns an error when meta.title is an empty string", () => { - const doc = buildDoc({ meta: { title: "" } }); + it('returns an error when meta.title is an empty string', () => { + const doc = buildDoc({ meta: { title: '' } }) - const result = validate(doc); + const result = validate(doc) - const titleErrors = errors(result).filter((e) => e.path === "meta.title"); - expect(titleErrors).toHaveLength(1); - }); + const titleErrors = errors(result).filter((e) => e.path === 'meta.title') + expect(titleErrors).toHaveLength(1) + }) - it("passes when meta and title are valid", () => { - const doc = buildDoc({ meta: { title: "My Lab" } }); + it('passes when meta and title are valid', () => { + const doc = buildDoc({ meta: { title: 'My Lab' } }) - const result = validate(doc); + const result = validate(doc) - const metaErrors = errors(result).filter((e) => - e.path.startsWith("meta"), - ); - expect(metaErrors).toHaveLength(0); - }); -}); + const metaErrors = errors(result).filter((e) => e.path.startsWith('meta')) + expect(metaErrors).toHaveLength(0) + }) +}) // ─── Device validation ──────────────────────────────────────────── -describe("validate › devices", () => { - it("returns an error when devices array is empty", () => { - const doc = buildDoc({ devices: [] }); +describe('validate › devices', () => { + it('returns an error when devices array is empty', () => { + const doc = buildDoc({ devices: [] }) - const result = validate(doc); + const result = validate(doc) - const deviceErrors = errors(result).filter((e) => e.path === "devices"); - expect(deviceErrors).toHaveLength(1); - expect(deviceErrors[0].message).toContain("At least one device"); - }); + const deviceErrors = errors(result).filter((e) => e.path === 'devices') + expect(deviceErrors).toHaveLength(1) + expect(deviceErrors[0].message).toContain('At least one device') + }) - it("returns an error when devices is not an array", () => { + it('returns an error when devices is not an array', () => { const doc = buildDoc({ - devices: "not-an-array" as unknown as HomelabDocument["devices"], - }); + devices: 'not-an-array' as unknown as HomelabDocument['devices'], + }) - const result = validate(doc); + const result = validate(doc) - const deviceErrors = errors(result).filter((e) => e.path === "devices"); - expect(deviceErrors).toHaveLength(1); - }); + const deviceErrors = errors(result).filter((e) => e.path === 'devices') + expect(deviceErrors).toHaveLength(1) + }) - it("returns an error when a device is missing an id", () => { + it('returns an error when a device is missing an id', () => { const doc = buildDoc({ - devices: [buildDevice({ id: "" })], - }); + devices: [buildDevice({ id: '' })], + }) - const result = validate(doc); + const result = validate(doc) - const idErrors = errors(result).filter((e) => e.path.includes(".id")); - expect(idErrors).toHaveLength(1); - }); + const idErrors = errors(result).filter((e) => e.path.includes('.id')) + expect(idErrors).toHaveLength(1) + }) - it("returns an error when a device is missing a name", () => { + it('returns an error when a device is missing a name', () => { const doc = buildDoc({ - devices: [buildDevice({ name: "" })], - }); + devices: [buildDevice({ name: '' })], + }) - const result = validate(doc); + const result = validate(doc) - const nameErrors = errors(result).filter((e) => e.path.includes(".name")); - expect(nameErrors).toHaveLength(1); - }); + const nameErrors = errors(result).filter((e) => e.path.includes('.name')) + expect(nameErrors).toHaveLength(1) + }) - it("returns a warning (not error) when a device is missing a type", () => { + it('returns a warning (not error) when a device is missing a type', () => { const doc = buildDoc({ - devices: [buildDevice({ type: "" })], - }); + devices: [buildDevice({ type: '' })], + }) - const result = validate(doc); + const result = validate(doc) - const typeWarnings = warnings(result).filter((e) => - e.path.includes(".type"), - ); - expect(typeWarnings).toHaveLength(1); + const typeWarnings = warnings(result).filter((e) => e.path.includes('.type')) + expect(typeWarnings).toHaveLength(1) - const typeErrors = errors(result).filter((e) => - e.path.includes(".type"), - ); - expect(typeErrors).toHaveLength(0); - }); + const typeErrors = errors(result).filter((e) => e.path.includes('.type')) + expect(typeErrors).toHaveLength(0) + }) - it("returns an error for duplicate device IDs", () => { + it('returns an error for duplicate device IDs', () => { const doc = buildDoc({ devices: [ - buildDevice({ id: "dup", name: "First" }), - buildDevice({ id: "dup", name: "Second" }), + buildDevice({ id: 'dup', name: 'First' }), + buildDevice({ id: 'dup', name: 'Second' }), ], - }); + }) - const result = validate(doc); + const result = validate(doc) - const dupeErrors = errors(result).filter((e) => - e.message.includes("Duplicate"), - ); - expect(dupeErrors).toHaveLength(1); - }); + const dupeErrors = errors(result).filter((e) => e.message.includes('Duplicate')) + expect(dupeErrors).toHaveLength(1) + }) - it("detects duplicate IDs across parent and child", () => { + it('detects duplicate IDs across parent and child', () => { const doc = buildDoc({ devices: [ { - id: "shared-id", - name: "Parent", - type: "hypervisor", - children: [{ id: "shared-id", name: "Child", type: "vm" }], + id: 'shared-id', + name: 'Parent', + type: 'hypervisor', + children: [{ id: 'shared-id', name: 'Child', type: 'vm' }], }, ], - }); + }) - const result = validate(doc); + const result = validate(doc) - const dupeErrors = errors(result).filter((e) => - e.message.includes("Duplicate"), - ); - expect(dupeErrors).toHaveLength(1); - }); + const dupeErrors = errors(result).filter((e) => e.message.includes('Duplicate')) + expect(dupeErrors).toHaveLength(1) + }) - it("passes when all devices are valid", () => { + it('passes when all devices are valid', () => { const doc = buildDoc({ - devices: [ - buildDevice({ id: "a", name: "A" }), - buildDevice({ id: "b", name: "B" }), - ], - }); + devices: [buildDevice({ id: 'a', name: 'A' }), buildDevice({ id: 'b', name: 'B' })], + }) - const result = validate(doc); + const result = validate(doc) - const deviceErrors = errors(result).filter((e) => - e.path.startsWith("devices"), - ); - expect(deviceErrors).toHaveLength(0); - }); -}); + const deviceErrors = errors(result).filter((e) => e.path.startsWith('devices')) + expect(deviceErrors).toHaveLength(0) + }) +}) // ─── Connection validation ──────────────────────────────────────── -describe("validate › connections", () => { +describe('validate › connections', () => { it("returns an error when a connection is missing 'from'", () => { const doc = buildDoc({ - connections: [buildConnection({ from: "" })], - }); + connections: [buildConnection({ from: '' })], + }) - const result = validate(doc); + const result = validate(doc) - const fromErrors = errors(result).filter((e) => - e.path.includes(".from"), - ); - expect(fromErrors).toHaveLength(1); - }); + const fromErrors = errors(result).filter((e) => e.path.includes('.from')) + expect(fromErrors).toHaveLength(1) + }) it("returns an error when a connection is missing 'to'", () => { const doc = buildDoc({ - connections: [buildConnection({ to: "" })], - }); + connections: [buildConnection({ to: '' })], + }) - const result = validate(doc); + const result = validate(doc) - const toErrors = errors(result).filter((e) => e.path.includes(".to")); - expect(toErrors).toHaveLength(1); - }); + const toErrors = errors(result).filter((e) => e.path.includes('.to')) + expect(toErrors).toHaveLength(1) + }) - it("returns an error when connections is not an array", () => { + it('returns an error when connections is not an array', () => { const doc = buildDoc({ - connections: "oops" as unknown as HomelabDocument["connections"], - }); + connections: 'oops' as unknown as HomelabDocument['connections'], + }) - const result = validate(doc); + const result = validate(doc) - const connErrors = errors(result).filter( - (e) => e.path === "connections", - ); - expect(connErrors).toHaveLength(1); - expect(connErrors[0].message).toContain("must be an array"); - }); + const connErrors = errors(result).filter((e) => e.path === 'connections') + expect(connErrors).toHaveLength(1) + expect(connErrors[0].message).toContain('must be an array') + }) - it("passes when connections are valid", () => { + it('passes when connections are valid', () => { const doc = buildDoc({ - devices: [ - buildDevice({ id: "a", name: "A" }), - buildDevice({ id: "b", name: "B" }), - ], - connections: [buildConnection({ from: "a", to: "b" })], - }); + devices: [buildDevice({ id: 'a', name: 'A' }), buildDevice({ id: 'b', name: 'B' })], + connections: [buildConnection({ from: 'a', to: 'b' })], + }) - const result = validate(doc); + const result = validate(doc) - const connErrors = errors(result).filter((e) => - e.path.startsWith("connections"), - ); - expect(connErrors).toHaveLength(0); - }); + const connErrors = errors(result).filter((e) => e.path.startsWith('connections')) + expect(connErrors).toHaveLength(0) + }) - it("accepts undefined connections without error", () => { - const doc = buildDoc(); - (doc as unknown as Record).connections = undefined; + it('accepts undefined connections without error', () => { + const doc = buildDoc() + ;(doc as unknown as Record).connections = undefined - const result = validate(doc as HomelabDocument); + const result = validate(doc as HomelabDocument) - const connErrors = errors(result).filter((e) => - e.path.startsWith("connections"), - ); - expect(connErrors).toHaveLength(0); - }); -}); + const connErrors = errors(result).filter((e) => e.path.startsWith('connections')) + expect(connErrors).toHaveLength(0) + }) +}) // ─── Reference validation ───────────────────────────────────────── -describe("validate › references", () => { +describe('validate › references', () => { it("returns an error when connection 'from' references a non-existent device", () => { const doc = buildDoc({ - devices: [buildDevice({ id: "real" })], - connections: [buildConnection({ from: "ghost", to: "real" })], - }); + devices: [buildDevice({ id: 'real' })], + connections: [buildConnection({ from: 'ghost', to: 'real' })], + }) - const result = validate(doc); + const result = validate(doc) - const refErrors = errors(result).filter((e) => - e.message.includes("ghost"), - ); - expect(refErrors).toHaveLength(1); - expect(refErrors[0].severity).toBe("error"); - }); + const refErrors = errors(result).filter((e) => e.message.includes('ghost')) + expect(refErrors).toHaveLength(1) + expect(refErrors[0].severity).toBe('error') + }) it("returns an error when connection 'to' references a non-existent device", () => { const doc = buildDoc({ - devices: [buildDevice({ id: "real" })], - connections: [buildConnection({ from: "real", to: "phantom" })], - }); + devices: [buildDevice({ id: 'real' })], + connections: [buildConnection({ from: 'real', to: 'phantom' })], + }) - const result = validate(doc); + const result = validate(doc) - const refErrors = errors(result).filter((e) => - e.message.includes("phantom"), - ); - expect(refErrors).toHaveLength(1); - expect(refErrors[0].severity).toBe("error"); - }); + const refErrors = errors(result).filter((e) => e.message.includes('phantom')) + expect(refErrors).toHaveLength(1) + expect(refErrors[0].severity).toBe('error') + }) - it("allows connections referencing child device IDs", () => { + it('allows connections referencing child device IDs', () => { const doc = buildDoc({ devices: [ { - id: "parent", - name: "Parent", - type: "hypervisor", - children: [{ id: "child-vm", name: "Child", type: "vm" }], + id: 'parent', + name: 'Parent', + type: 'hypervisor', + children: [{ id: 'child-vm', name: 'Child', type: 'vm' }], }, - buildDevice({ id: "switch", name: "Switch", type: "switch" }), + buildDevice({ id: 'switch', name: 'Switch', type: 'switch' }), ], - connections: [buildConnection({ from: "child-vm", to: "switch" })], - }); + connections: [buildConnection({ from: 'child-vm', to: 'switch' })], + }) - const result = validate(doc); + const result = validate(doc) - const refErrors = errors(result).filter((e) => - e.path.startsWith("connections"), - ); - expect(refErrors).toHaveLength(0); - }); + const refErrors = errors(result).filter((e) => e.path.startsWith('connections')) + expect(refErrors).toHaveLength(0) + }) - it("returns a warning when a device references an undefined network", () => { + it('returns a warning when a device references an undefined network', () => { const doc = buildDoc({ - networks: [{ id: "lan", name: "LAN" }], - devices: [buildDevice({ id: "srv", network: "nonexistent" })], - }); + networks: [{ id: 'lan', name: 'LAN' }], + devices: [buildDevice({ id: 'srv', network: 'nonexistent' })], + }) - const result = validate(doc); + const result = validate(doc) - const netWarnings = warnings(result).filter((e) => - e.path.includes(".network"), - ); - expect(netWarnings).toHaveLength(1); - expect(netWarnings[0].message).toContain("nonexistent"); - }); + const netWarnings = warnings(result).filter((e) => e.path.includes('.network')) + expect(netWarnings).toHaveLength(1) + expect(netWarnings[0].message).toContain('nonexistent') + }) - it("returns a warning when a device references an undefined group", () => { + it('returns a warning when a device references an undefined group', () => { const doc = buildDoc({ - groups: [{ id: "rack", name: "Rack" }], - devices: [buildDevice({ id: "srv", group: "no-such-group" })], - }); + groups: [{ id: 'rack', name: 'Rack' }], + devices: [buildDevice({ id: 'srv', group: 'no-such-group' })], + }) - const result = validate(doc); + const result = validate(doc) - const grpWarnings = warnings(result).filter((e) => - e.path.includes(".group"), - ); - expect(grpWarnings).toHaveLength(1); - expect(grpWarnings[0].message).toContain("no-such-group"); - }); + const grpWarnings = warnings(result).filter((e) => e.path.includes('.group')) + expect(grpWarnings).toHaveLength(1) + expect(grpWarnings[0].message).toContain('no-such-group') + }) - it("passes when all references are valid", () => { + it('passes when all references are valid', () => { const doc = buildDoc({ - networks: [{ id: "lan", name: "LAN" }], - groups: [{ id: "rack", name: "Rack" }], + networks: [{ id: 'lan', name: 'LAN' }], + groups: [{ id: 'rack', name: 'Rack' }], devices: [ - buildDevice({ id: "a", name: "A", network: "lan", group: "rack" }), - buildDevice({ id: "b", name: "B" }), + buildDevice({ id: 'a', name: 'A', network: 'lan', group: 'rack' }), + buildDevice({ id: 'b', name: 'B' }), ], - connections: [buildConnection({ from: "a", to: "b" })], - }); + connections: [buildConnection({ from: 'a', to: 'b' })], + }) - const result = validate(doc); + const result = validate(doc) - expect(errors(result)).toHaveLength(0); - expect(warnings(result)).toHaveLength(0); - }); + expect(errors(result)).toHaveLength(0) + expect(warnings(result)).toHaveLength(0) + }) - it("only checks top-level devices for network/group refs, not children", () => { + it('only checks top-level devices for network/group refs, not children', () => { const doc = buildDoc({ devices: [ { - id: "parent", - name: "Parent", - type: "hypervisor", + id: 'parent', + name: 'Parent', + type: 'hypervisor', children: [ { - id: "child", - name: "Child", - type: "vm", - network: "fake-net", - group: "fake-group", + id: 'child', + name: 'Child', + type: 'vm', + network: 'fake-net', + group: 'fake-group', }, ], }, ], - }); + }) - const result = validate(doc); + const result = validate(doc) // Children's network/group refs are NOT checked, so no warning expected. const refWarnings = warnings(result).filter( - (e) => e.path.includes(".network") || e.path.includes(".group"), - ); - expect(refWarnings).toHaveLength(0); - }); -}); + (e) => e.path.includes('.network') || e.path.includes('.group'), + ) + expect(refWarnings).toHaveLength(0) + }) +}) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6c1aa14..91f1908 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,8 +1,8 @@ -export { layout } from "./layout"; -export { parse } from "./parser"; -export { validate } from "./validator"; -export type { ParseResult } from "./parser"; - +export { assignPorts, getPortX, getPortStripY, PORT_DIMENSIONS } from './ports' +export { DEFAULT_LAYOUT_OPTIONS } from './types' +export { layout } from './layout' +export { parse, type ParseResult } from './parser' +export { validate } from './validator' export type { HomelabDocument, MetaConfig, @@ -23,6 +23,8 @@ export type { Bounds, ValidationError, LayoutOptions, -} from "./types"; - -export { DEFAULT_LAYOUT_OPTIONS } from "./types"; + DeviceInterfaces, + InterfaceGroup, + WifiInterface, +} from './types' +export type { PortAssignment, PortLayout } from './ports' diff --git a/packages/core/src/layout.ts b/packages/core/src/layout.ts index 0a802c4..0d3f0b9 100644 --- a/packages/core/src/layout.ts +++ b/packages/core/src/layout.ts @@ -1,56 +1,55 @@ -import type { - HomelabDocument, - Device, - Connection, - PositionedGraph, - PositionedNode, - PositionedEdge, - PositionedGroup, - LayoutOptions, - Point, -} from "./types"; -import { DEFAULT_LAYOUT_OPTIONS } from "./types"; - -export function layout( - doc: HomelabDocument, - userOptions?: LayoutOptions, -): PositionedGraph { - const opts = { ...DEFAULT_LAYOUT_OPTIONS, ...userOptions }; - const expanded = opts.expanded ?? new Set(); - - // 1. Separate top-level devices from nested children - const topLevel = doc.devices.map((d) => stripChildren(d)); - - // 2. Build hierarchy from connections (top-level only for depth assignment) - const topIds = new Set(topLevel.map((d) => d.id)); - const { childrenMap, roots } = buildHierarchy(topLevel, doc.connections ?? []); - - // 3. BFS depth for top-level devices - const depthMap = assignDepths(roots, childrenMap); - - // 4. Build layers from top-level devices - const layers = buildLayers(topLevel, depthMap); - - // 5. Position all nodes — top-level in layers, children as sub-rows - const nodeMap = positionAll(layers, doc.devices, expanded, opts); - - // 6. Reroute connections for collapsed children - const rerouteMap = buildRerouteMap(doc.devices, expanded); - const visibleIds = new Set(Array.from(nodeMap.keys())); - const rerouted = rerouteConnections(doc.connections ?? [], rerouteMap, visibleIds); - - // 7. Group outlines - const allVisible = collectVisibleFlat(doc.devices, expanded); - const groups = positionGroups(doc.groups ?? [], allVisible, nodeMap, opts); - - // 8. Normalize to positive coordinates - normalizePositions(nodeMap, groups, opts.groupPadding); - - // 9. Route edges - const edges = routeEdges(rerouted, nodeMap); - - // 10. Bounds - const bounds = computeBounds(nodeMap, groups, opts); +import { assignPorts, getPortX } from './ports' +import { + DEFAULT_LAYOUT_OPTIONS, + type HomelabDocument, + type Device, + type Connection, + type PositionedGraph, + type PositionedNode, + type PositionedEdge, + type PositionedGroup, + type LayoutOptions, + type Point, +} from './types' +import type { PortAssignment } from './ports' + +export function layout(doc: HomelabDocument, userOptions?: LayoutOptions): PositionedGraph { + const opts = { ...DEFAULT_LAYOUT_OPTIONS, ...userOptions } + + // 1. Top-level devices only + const topLevel = doc.devices.map((d): Device => ({ ...d, children: undefined })) + + // 2. Connection hierarchy for depth + const { childrenMap, roots } = buildHierarchy(topLevel, doc.connections ?? []) + + // 3. BFS depth + const depthMap = assignDepths(roots, childrenMap) + + // 4. Layers + const layers = buildLayers(topLevel, depthMap) + + // 5. Position nodes + const nodeMap = positionLayers(layers, opts) + + // 6. Reroute connections targeting children + const rerouteMap = buildRerouteMap(doc.devices) + const visibleIds = new Set(topLevel.map((d) => d.id)) + const rerouted = rerouteConnections(doc.connections ?? [], rerouteMap, visibleIds) + + // 7. Assign ports using original devices (for interface info) and rerouted connections + const portAssignments = assignPorts(doc.devices, rerouted) + + // 8. Group outlines + const groups = positionGroups(doc.groups ?? [], topLevel, nodeMap, opts) + + // 9. Normalize + normalizePositions(nodeMap, groups, opts.groupPadding) + + // 10. Route edges (port-aware) + const edges = routeEdges(rerouted, nodeMap, doc.devices, portAssignments) + + // 11. Bounds + const bounds = computeBounds(nodeMap, groups, opts) return { nodes: Array.from(nodeMap.values()), @@ -58,225 +57,136 @@ export function layout( groups, bounds, meta: doc.meta, - }; -} - -// ─── Helpers ────────────────────────────────────────────────────── - -interface FlatDevice extends Device { - _parentId?: string; -} - -function stripChildren(d: Device): FlatDevice { - const copy: any = { ...d }; - delete copy.children; - return copy as FlatDevice; -} - -function collectVisibleFlat( - devices: Device[], - expanded: Set, - parentId?: string, -): FlatDevice[] { - const result: FlatDevice[] = []; - for (const d of devices) { - const flat: FlatDevice = { ...d, _parentId: parentId }; - delete (flat as any).children; - result.push(flat); - if (d.children && expanded.has(d.id)) { - result.push(...collectVisibleFlat(d.children, expanded, d.id)); - } + portAssignments, } - return result; } +// ─── Hierarchy ──────────────────────────────────────────────────── + function buildHierarchy( - devices: FlatDevice[], + devices: Device[], connections: Connection[], ): { - parentMap: Map; - childrenMap: Map; - roots: string[]; + parentMap: Map + childrenMap: Map + roots: string[] } { - const ids = new Set(devices.map((d) => d.id)); - const parentMap = new Map(); - const childrenMap = new Map(); + const ids = new Set(devices.map((d) => d.id)) + const parentMap = new Map() + const childrenMap = new Map() for (const conn of connections) { - if (!ids.has(conn.from) || !ids.has(conn.to)) continue; + if (!ids.has(conn.from) || !ids.has(conn.to)) continue if (!parentMap.has(conn.to)) { - parentMap.set(conn.to, conn.from); + parentMap.set(conn.to, conn.from) } - const existing = childrenMap.get(conn.from) ?? []; + const existing = childrenMap.get(conn.from) ?? [] if (!existing.includes(conn.to)) { - existing.push(conn.to); - childrenMap.set(conn.from, existing); + existing.push(conn.to) + childrenMap.set(conn.from, existing) } } - const roots = devices - .filter((d) => !parentMap.has(d.id)) - .map((d) => d.id); + const roots = devices.filter((d) => !parentMap.has(d.id)).map((d) => d.id) - return { parentMap, childrenMap, roots }; + return { parentMap, childrenMap, roots } } -function assignDepths( - roots: string[], - childrenMap: Map, -): Map { - const depthMap = new Map(); +function assignDepths(roots: string[], childrenMap: Map): Map { + const depthMap = new Map() const queue: Array<{ id: string; depth: number }> = roots.map((id) => ({ id, depth: 0, - })); + })) while (queue.length > 0) { - const { id, depth } = queue.shift()!; - if (depthMap.has(id)) continue; - depthMap.set(id, depth); + const { id, depth } = queue.shift()! + if (depthMap.has(id)) continue + depthMap.set(id, depth) for (const childId of childrenMap.get(id) ?? []) { if (!depthMap.has(childId)) { - queue.push({ id: childId, depth: depth + 1 }); + queue.push({ id: childId, depth: depth + 1 }) } } } - return depthMap; + return depthMap } -function buildLayers( - devices: FlatDevice[], - depthMap: Map, -): FlatDevice[][] { - const maxDepth = Math.max(0, ...depthMap.values()); - const layers: FlatDevice[][] = Array.from({ length: maxDepth + 1 }, () => []); +function buildLayers(devices: Device[], depthMap: Map): Device[][] { + const maxDepth = Math.max(0, ...depthMap.values()) + const layers: Device[][] = Array.from({ length: maxDepth + 1 }, () => []) for (const d of devices) { - const depth = depthMap.get(d.id) ?? 0; - layers[depth].push(d); + const depth = depthMap.get(d.id) ?? 0 + layers[depth].push(d) } - return layers; + return layers } -/** - * Positions top-level layers, then inserts sub-rows for expanded - * children directly beneath their parent. Subsequent rows shift down. - */ -function positionAll( - layers: FlatDevice[][], - originalDevices: Device[], - expanded: Set, +// ─── Node positioning ───────────────────────────────────────────── + +function positionLayers( + layers: Device[][], opts: Required, ): Map { - const nodeMap = new Map(); - const subRowHeight = opts.nodeHeight + 30; - const layerGap = opts.verticalSpacing; + const nodeMap = new Map() + const groupGap = opts.groupPadding * 2 + 16 - // Build a lookup from id → original device (with children intact) - const origMap = new Map(); - const walkOrig = (devs: Device[]) => { - for (const d of devs) { - origMap.set(d.id, d); - if (d.children) walkOrig(d.children); - } - }; - walkOrig(originalDevices); + let currentY = 0 - let currentY = 0; + for (let depth = 0; depth < layers.length; depth++) { + const layer = layers[depth] - for (let layerIdx = 0; layerIdx < layers.length; layerIdx++) { - const layer = layers[layerIdx]; + const gaps: number[] = [] + for (let i = 1; i < layer.length; i++) { + const prevGroup = layer[i - 1].group ?? '' + const currGroup = layer[i].group ?? '' + const sameGroup = prevGroup !== '' && currGroup !== '' && prevGroup === currGroup + gaps.push(sameGroup ? opts.horizontalSpacing : Math.max(opts.horizontalSpacing, groupGap)) + } - // Position this layer's nodes - const layerWidth = - layer.length * opts.nodeWidth + - (layer.length - 1) * opts.horizontalSpacing; - const startX = -layerWidth / 2; + const totalGaps = gaps.reduce((sum, g) => sum + g, 0) + const layerWidth = layer.length * opts.nodeWidth + totalGaps + let cursorX = -layerWidth / 2 for (let i = 0; i < layer.length; i++) { - const device = layer[i]; + const device = layer[i] + nodeMap.set(device.id, { device, - x: startX + i * (opts.nodeWidth + opts.horizontalSpacing), + x: cursorX, y: currentY, width: opts.nodeWidth, height: opts.nodeHeight, - depth: layerIdx, - }); - } + depth, + }) - currentY += opts.nodeHeight; - - // Check if any node in this layer is expanded — if so, insert sub-rows - const expandedInLayer = layer.filter( - (d) => expanded.has(d.id) && origMap.get(d.id)?.children?.length, - ); - - if (expandedInLayer.length > 0) { - currentY += 30; // gap between parent row and children - - for (const parent of expandedInLayer) { - const orig = origMap.get(parent.id); - if (!orig?.children) continue; - - const parentNode = nodeMap.get(parent.id)!; - const children = orig.children; - const childWidth = - children.length * opts.nodeWidth + - (children.length - 1) * opts.horizontalSpacing; - - // Centre children under the parent - const childStartX = parentNode.x + parentNode.width / 2 - childWidth / 2; - - for (let ci = 0; ci < children.length; ci++) { - const child = stripChildren(children[ci]); - nodeMap.set(child.id, { - device: child, - x: childStartX + ci * (opts.nodeWidth + opts.horizontalSpacing), - y: currentY, - width: opts.nodeWidth, - height: opts.nodeHeight, - depth: layerIdx + 0.5, - }); - } + cursorX += opts.nodeWidth + if (i < gaps.length) { + cursorX += gaps[i] } - - currentY += opts.nodeHeight; } - currentY += layerGap; + currentY += opts.nodeHeight + opts.verticalSpacing } - return nodeMap; + return nodeMap } // ─── Connection rerouting ───────────────────────────────────────── -function buildRerouteMap( - devices: Device[], - expanded: Set, -): Map { - const map = new Map(); - - const walk = (devs: Device[]) => { - for (const d of devs) { - if (!d.children) continue; - if (expanded.has(d.id)) { - walk(d.children); - } else { - const mapDescendants = (children: Device[], target: string) => { - for (const child of children) { - map.set(child.id, target); - if (child.children) mapDescendants(child.children, target); - } - }; - mapDescendants(d.children, d.id); - } +function buildRerouteMap(devices: Device[]): Map { + const map = new Map() + const mapDescendants = (children: Device[], target: string) => { + for (const child of children) { + map.set(child.id, target) + if (child.children) mapDescendants(child.children, target) } - }; - - walk(devices); - return map; + } + for (const d of devices) { + if (d.children) mapDescendants(d.children, d.id) + } + return map } function rerouteConnections( @@ -284,140 +194,252 @@ function rerouteConnections( rerouteMap: Map, visibleIds: Set, ): Connection[] { - const seen = new Set(); - const result: Connection[] = []; + const seen = new Set() + const result: Connection[] = [] for (const conn of connections) { - const from = rerouteMap.get(conn.from) ?? conn.from; - const to = rerouteMap.get(conn.to) ?? conn.to; + const from = rerouteMap.get(conn.from) ?? conn.from + const to = rerouteMap.get(conn.to) ?? conn.to - if (!visibleIds.has(from) || !visibleIds.has(to)) continue; - if (from === to) continue; + if (!visibleIds.has(from) || !visibleIds.has(to)) continue + if (from === to) continue - const key = `${from}→${to}`; - if (seen.has(key)) continue; - seen.add(key); + const key = `${from}→${to}` + if (seen.has(key)) continue + seen.add(key) - result.push({ ...conn, from, to }); + result.push({ ...conn, from, to }) } - return result; + return result } -// ─── Edge routing ───────────────────────────────────────────────── +// ─── Edge routing (port-aware) ──────────────────────────────────── function routeEdges( connections: Connection[], nodeMap: Map, + originalDevices: Device[], + portAssignments: Map, ): PositionedEdge[] { - const bySource = new Map(); - for (const conn of connections) { - const list = bySource.get(conn.from) ?? []; - list.push(conn); - bySource.set(conn.from, list); + // Build a lookup for original devices (with interfaces) + const deviceLookup = new Map() + const walkDevices = (devs: Device[]) => { + for (const d of devs) { + deviceLookup.set(d.id, d) + if (d.children) walkDevices(d.children) + } } + walkDevices(originalDevices) + + // For channel routing: group by gap + interface EdgeInfo { + connection: Connection + fromNode: PositionedNode + toNode: PositionedNode + exitX: number + exitY: number + entryX: number + entryY: number + gapKey: string + fromPortIndex?: number + toPortIndex?: number + } + + const edgeInfos: EdgeInfo[] = [] - const byTarget = new Map(); + // Default fan-out tracking + const bySource = new Map() + const byTarget = new Map() for (const conn of connections) { - const list = byTarget.get(conn.to) ?? []; - list.push(conn); - byTarget.set(conn.to, list); + if (!nodeMap.has(conn.from) || !nodeMap.has(conn.to)) continue + const sf = bySource.get(conn.from) ?? [] + sf.push(conn) + bySource.set(conn.from, sf) + const tf = byTarget.get(conn.to) ?? [] + tf.push(conn) + byTarget.set(conn.to, tf) } - const channelSpacing = 12; - - return connections - .map((conn) => { - const fromNode = nodeMap.get(conn.from); - const toNode = nodeMap.get(conn.to); - if (!fromNode || !toNode) return null; - - const siblings = bySource.get(conn.from) ?? [conn]; - const sibIndex = siblings.indexOf(conn); - const sibCount = siblings.length; - const exitSpread = Math.min(fromNode.width * 0.7, sibCount * 24); - const exitStartX = fromNode.x + fromNode.width / 2 - exitSpread / 2; - const exitX = - sibCount === 1 - ? fromNode.x + fromNode.width / 2 - : exitStartX + (sibIndex / (sibCount - 1)) * exitSpread; - - const targetSiblings = byTarget.get(conn.to) ?? [conn]; - const targetIndex = targetSiblings.indexOf(conn); - const targetCount = targetSiblings.length; - const entrySpread = Math.min(toNode.width * 0.7, targetCount * 24); - const entryStartX = toNode.x + toNode.width / 2 - entrySpread / 2; - const entryX = + for (const conn of connections) { + const fromNode = nodeMap.get(conn.from) + const toNode = nodeMap.get(conn.to) + if (!fromNode || !toNode) continue + + const fromDevice = deviceLookup.get(conn.from) + const toDevice = deviceLookup.get(conn.to) + + // Find port assignments for this connection + const fromPorts = portAssignments.get(conn.from) ?? [] + const toPorts = portAssignments.get(conn.to) ?? [] + const fromPort = fromPorts.find((p) => p.connectedTo === conn.to && p.interfaceType !== 'wifi') + const toPort = toPorts.find((p) => p.connectedTo === conn.from && p.interfaceType !== 'wifi') + + let exitX: number + let entryX: number + + // Compute exit X: port-level or fan-spread fallback + if (fromPort && fromDevice?.interfaces) { + const iface = + fromDevice.interfaces[fromPort.interfaceType as keyof typeof fromDevice.interfaces] + const totalPorts = iface && 'count' in iface ? iface.count : 1 + exitX = fromNode.x + getPortX(fromPort.portIndex, totalPorts, fromNode.width) + } else { + // Fan-spread fallback + const siblings = bySource.get(conn.from) ?? [conn] + const sibIndex = siblings.indexOf(conn) + const sibCount = siblings.length + const spread = Math.min(fromNode.width * 0.6, sibCount * 20) + const center = fromNode.x + fromNode.width / 2 + exitX = sibCount === 1 ? center : center - spread / 2 + (sibIndex / (sibCount - 1)) * spread + } + + // Compute entry X: port-level or fan-spread fallback + if (toPort && toDevice?.interfaces) { + const iface = toDevice.interfaces[toPort.interfaceType as keyof typeof toDevice.interfaces] + const totalPorts = iface && 'count' in iface ? iface.count : 1 + entryX = toNode.x + getPortX(toPort.portIndex, totalPorts, toNode.width) + } else { + const targetSiblings = byTarget.get(conn.to) ?? [conn] + const targetIndex = targetSiblings.indexOf(conn) + const targetCount = targetSiblings.length + const spread = Math.min(toNode.width * 0.6, targetCount * 20) + const center = toNode.x + toNode.width / 2 + entryX = targetCount === 1 - ? toNode.x + toNode.width / 2 - : entryStartX + (targetIndex / (targetCount - 1)) * entrySpread; + ? center + : center - spread / 2 + (targetIndex / (targetCount - 1)) * spread + } + + const exitY = fromNode.y + fromNode.height + const entryY = toNode.y + const gapKey = `${fromNode.depth}→${toNode.depth}` + + edgeInfos.push({ + connection: conn, + fromNode, + toNode, + exitX, + exitY, + entryX, + entryY, + gapKey, + fromPortIndex: fromPort?.portIndex, + toPortIndex: toPort?.portIndex, + }) + } - const fromPt: Point = { x: exitX, y: fromNode.y + fromNode.height }; - const toPt: Point = { x: entryX, y: toNode.y }; + // Assign channels per gap + const gapGroups = new Map() + for (const info of edgeInfos) { + const list = gapGroups.get(info.gapKey) ?? [] + list.push(info) + gapGroups.set(info.gapKey, list) + } - const midBase = (fromPt.y + toPt.y) / 2; - const channelOffset = - sibCount <= 1 - ? 0 - : (sibIndex - (sibCount - 1) / 2) * channelSpacing; - const midY = midBase + channelOffset; + const channelMap = new Map() + + for (const [, group] of gapGroups) { + if (group.length === 0) continue + + const sorted = [...group].sort((a, b) => a.entryX - b.entryX) + const gapTop = Math.min(...sorted.map((e) => e.exitY)) + const gapBottom = Math.max(...sorted.map((e) => e.entryY)) + + const margin = 15 + const usableTop = gapTop + margin + const usableBottom = gapBottom - margin + const usableSpace = usableBottom - usableTop + const minSpacing = 8 + const count = sorted.length + + if (count === 1) { + channelMap.set(sorted[0], (usableTop + usableBottom) / 2) + } else { + const idealSpacing = usableSpace / (count - 1) + const spacing = Math.max(minSpacing, idealSpacing) + const totalNeeded = spacing * (count - 1) + const startY = usableTop + (usableSpace - totalNeeded) / 2 + for (let i = 0; i < count; i++) { + channelMap.set(sorted[i], startY + i * spacing) + } + } + } - const isAligned = Math.abs(fromPt.x - toPt.x) < 4; - const points: Point[] = isAligned - ? [fromPt, toPt] - : [fromPt, { x: fromPt.x, y: midY }, { x: toPt.x, y: midY }, toPt]; + // Build paths + return edgeInfos.map((info) => { + const channelY = channelMap.get(info)! + const isAligned = Math.abs(info.exitX - info.entryX) < 6 + + let points: Point[] + if (isAligned) { + points = [ + { x: info.exitX, y: info.exitY }, + { x: info.entryX, y: info.entryY }, + ] + } else { + points = [ + { x: info.exitX, y: info.exitY }, + { x: info.exitX, y: channelY }, + { x: info.entryX, y: channelY }, + { x: info.entryX, y: info.entryY }, + ] + } - return { - connection: conn, - points, - fromNodeId: conn.from, - toNodeId: conn.to, - }; - }) - .filter(Boolean) as PositionedEdge[]; + return { + connection: info.connection, + points, + fromNodeId: info.connection.from, + toNodeId: info.connection.to, + fromPortIndex: info.fromPortIndex, + toPortIndex: info.toPortIndex, + } + }) } // ─── Groups ─────────────────────────────────────────────────────── function positionGroups( - groups: HomelabDocument["groups"], - devices: FlatDevice[], + groups: HomelabDocument['groups'], + devices: Device[], nodeMap: Map, opts: Required, ): PositionedGroup[] { - if (!groups) return []; + if (!groups) return [] + + const pad = opts.groupPadding + const topPad = pad + 16 return groups .map((group) => { - const members = devices.filter((d) => d.group === group.id); - if (members.length === 0) return null; + const members = devices.filter((d) => d.group === group.id) + if (members.length === 0) return null let minX = Infinity, minY = Infinity, maxX = -Infinity, - maxY = -Infinity; + maxY = -Infinity for (const m of members) { - const node = nodeMap.get(m.id); - if (!node) continue; - minX = Math.min(minX, node.x); - minY = Math.min(minY, node.y); - maxX = Math.max(maxX, node.x + node.width); - maxY = Math.max(maxY, node.y + node.height); + const node = nodeMap.get(m.id) + if (!node) continue + minX = Math.min(minX, node.x) + minY = Math.min(minY, node.y) + maxX = Math.max(maxX, node.x + node.width) + maxY = Math.max(maxY, node.y + node.height) } - if (!isFinite(minX)) return null; + if (!isFinite(minX)) return null - const pad = opts.groupPadding; return { group, x: minX - pad, - y: minY - pad, + y: minY - topPad, width: maxX - minX + pad * 2, - height: maxY - minY + pad * 2, - }; + height: maxY - minY + topPad + pad, + } }) - .filter(Boolean) as PositionedGroup[]; + .filter(Boolean) as PositionedGroup[] } // ─── Normalization & bounds ─────────────────────────────────────── @@ -427,28 +449,28 @@ function normalizePositions( groups: PositionedGroup[], padding: number, ): void { - let minX = Infinity; - let minY = Infinity; + let minX = Infinity + let minY = Infinity for (const node of nodeMap.values()) { - minX = Math.min(minX, node.x); - minY = Math.min(minY, node.y); + minX = Math.min(minX, node.x) + minY = Math.min(minY, node.y) } for (const g of groups) { - minX = Math.min(minX, g.x); - minY = Math.min(minY, g.y); + minX = Math.min(minX, g.x) + minY = Math.min(minY, g.y) } - const shiftX = -minX + padding; - const shiftY = -minY + padding; + const shiftX = -minX + padding + const shiftY = -minY + padding for (const node of nodeMap.values()) { - node.x += shiftX; - node.y += shiftY; + node.x += shiftX + node.y += shiftY } for (const g of groups) { - g.x += shiftX; - g.y += shiftY; + g.x += shiftX + g.y += shiftY } } @@ -457,19 +479,19 @@ function computeBounds( groups: PositionedGroup[], opts: Required, ): { width: number; height: number } { - let maxX = 0; - let maxY = 0; + let maxX = 0 + let maxY = 0 for (const node of nodeMap.values()) { - maxX = Math.max(maxX, node.x + node.width); - maxY = Math.max(maxY, node.y + node.height); + maxX = Math.max(maxX, node.x + node.width) + maxY = Math.max(maxY, node.y + node.height) } for (const g of groups) { - maxX = Math.max(maxX, g.x + g.width); - maxY = Math.max(maxY, g.y + g.height); + maxX = Math.max(maxX, g.x + g.width) + maxY = Math.max(maxY, g.y + g.height) } - const pad = opts.groupPadding * 2; - return { width: maxX + pad, height: maxY + pad }; + const pad = opts.groupPadding * 2 + return { width: maxX + pad, height: maxY + pad } } diff --git a/packages/core/src/parser.ts b/packages/core/src/parser.ts index c123b57..55b964c 100644 --- a/packages/core/src/parser.ts +++ b/packages/core/src/parser.ts @@ -1,10 +1,10 @@ -import yaml from "js-yaml"; -import { validate } from "./validator"; -import type { HomelabDocument, ValidationError } from "./types"; +import yaml from 'js-yaml' +import { validate } from './validator' +import type { HomelabDocument, ValidationError, DeviceSpecs } from './types' export type ParseResult = | { ok: true; document: HomelabDocument; warnings: ValidationError[] } - | { ok: false; errors: ValidationError[] }; + | { ok: false; errors: ValidationError[] } /** * Parses a YAML string into a validated HomelabDocument. @@ -14,40 +14,37 @@ export type ParseResult = */ export function parse(yamlString: string): ParseResult { // Phase 1: YAML syntax parsing - let raw: unknown; + let raw: unknown try { - raw = yaml.load(yamlString); + raw = yaml.load(yamlString) } catch (err: unknown) { - const message = - err instanceof Error ? err.message : "Invalid YAML syntax."; + const message = err instanceof Error ? err.message : 'Invalid YAML syntax.' return { ok: false, - errors: [{ path: "", message, severity: "error" }], - }; + errors: [{ path: '', message, severity: 'error' }], + } } - if (!raw || typeof raw !== "object") { + if (!raw || typeof raw !== 'object') { return { ok: false, - errors: [ - { path: "", message: "Document root must be a mapping.", severity: "error" }, - ], - }; + errors: [{ path: '', message: 'Document root must be a mapping.', severity: 'error' }], + } } // Phase 2: Coerce into our typed shape (light normalization) - const doc = normalizeDocument(raw as Record); + const doc = normalizeDocument(raw as Record) // Phase 3: Structural validation - const allErrors = validate(doc); - const errors = allErrors.filter((e) => e.severity === "error"); - const warnings = allErrors.filter((e) => e.severity === "warning"); + const allErrors = validate(doc) + const errors = allErrors.filter((e) => e.severity === 'error') + const warnings = allErrors.filter((e) => e.severity === 'warning') if (errors.length > 0) { - return { ok: false, errors: [...errors, ...warnings] }; + return { ok: false, errors: [...errors, ...warnings] } } - return { ok: true, document: doc, warnings }; + return { ok: true, document: doc, warnings } } // ─── Normalization helpers ──────────────────────────────────────── @@ -61,55 +58,61 @@ function normalizeDocument(raw: Record): HomelabDocument { groups: Array.isArray(raw.groups) ? raw.groups : undefined, devices: Array.isArray(raw.devices) ? raw.devices.map(normalizeDevice) : [], connections: Array.isArray(raw.connections) ? raw.connections : [], - }; + } } -function normalizeMeta(raw: unknown): HomelabDocument["meta"] { - if (!raw || typeof raw !== "object") { - return { title: "" }; +function normalizeMeta(raw: unknown): HomelabDocument['meta'] { + if (!raw || typeof raw !== 'object') { + return { title: '' } } - const r = raw as Record; + const r = raw as Record return { - title: String(r.title ?? ""), + title: String(r.title ?? ''), subtitle: r.subtitle ? String(r.subtitle) : undefined, author: r.author ? String(r.author) : undefined, date: r.date ? String(r.date) : undefined, tags: Array.isArray(r.tags) ? r.tags.map(String) : undefined, - }; + } } -function normalizeDevice(raw: unknown): HomelabDocument["devices"][number] { - if (!raw || typeof raw !== "object") { - return { id: "", name: "", type: "unknown" }; +function normalizeDevice(raw: unknown): HomelabDocument['devices'][number] { + if (!raw || typeof raw !== 'object') { + return { id: '', name: '', type: 'unknown' } } - const r = raw as Record; + const r = raw as Record return { - id: String(r.id ?? ""), - name: String(r.name ?? ""), - type: String(r.type ?? "unknown"), + id: String(r.id ?? ''), + name: String(r.name ?? ''), + type: String(r.type ?? 'unknown'), ip: r.ip ? String(r.ip) : undefined, network: r.network ? String(r.network) : undefined, group: r.group ? String(r.group) : undefined, tags: Array.isArray(r.tags) ? r.tags.map(String) : undefined, - specs: r.specs && typeof r.specs === "object" ? (r.specs as any) : undefined, + specs: r.specs && typeof r.specs === 'object' ? (r.specs as DeviceSpecs) : undefined, metadata: - r.metadata && typeof r.metadata === "object" + r.metadata && typeof r.metadata === 'object' ? (r.metadata as Record) : undefined, - children: Array.isArray(r.children) - ? r.children.map(normalizeDevice) - : undefined, + children: Array.isArray(r.children) ? r.children.map(normalizeDevice) : undefined, services: Array.isArray(r.services) - ? r.services.map((s: any) => ({ - name: String(s?.name ?? ""), - port: s?.port != null ? Number(s.port) : undefined, - runtime: s?.runtime ? String(s.runtime) : undefined, - url: s?.url ? String(s.url) : undefined, - metadata: - s?.metadata && typeof s.metadata === "object" - ? (s.metadata as Record) - : undefined, - })) + ? r.services.map((s: unknown) => { + const obj = + s && typeof s === 'object' && !Array.isArray(s) ? (s as Record) : {} + return { + name: String(obj.name ?? ''), + port: obj.port != null ? Number(obj.port) : undefined, + runtime: obj.runtime ? String(obj.runtime) : undefined, + url: obj.url ? String(obj.url) : undefined, + metadata: + obj.metadata && typeof obj.metadata === 'object' && !Array.isArray(obj.metadata) + ? (obj.metadata as Record) + : undefined, + } + }) : undefined, - }; + interfaces: + r.interfaces && typeof r.interfaces === 'object' + ? (r.interfaces as Record) + : undefined, + } } diff --git a/packages/core/src/ports.ts b/packages/core/src/ports.ts new file mode 100644 index 0000000..fa6aafc --- /dev/null +++ b/packages/core/src/ports.ts @@ -0,0 +1,114 @@ +import type { Device, Connection } from './types' + +export interface PortAssignment { + deviceId: string + interfaceType: 'ethernet' | 'wifi' | 'sfp' + portIndex: number + connectedTo: string + speed?: string +} + +export interface PortLayout { + /** X offset from the left edge of the card to the center of this port */ + x: number + /** Whether this port is connected */ + active: boolean + /** What device this port connects to */ + connectedTo?: string + /** Connection speed */ + speed?: string +} + +/** Width of a single port icon + gap */ +const PORT_WIDTH = 18 +const PORT_GAP = 4 +const PORT_STRIP_PADDING = 12 + +/** + * Computes which port on each device each connection uses. + * Ethernet/SFP connections consume physical ports in order. + * WiFi connections don't consume ports but are tracked for the client count. + */ +export function assignPorts( + devices: Device[], + connections: Connection[], +): Map { + const assignments = new Map() + + // Track port usage per device + const portCounters = new Map>() + + for (const device of devices) { + assignments.set(device.id, []) + portCounters.set(device.id, new Map()) + } + + for (const conn of connections) { + const connType = conn.type ?? 'ethernet' + const isWifi = connType === 'wifi' + const ifaceType = isWifi + ? 'wifi' + : connType === 'fiber' || connType === 'sfp' + ? 'sfp' + : 'ethernet' + + // Assign port on the 'from' device + assignPort(conn.from, conn.to, ifaceType, conn.speed, assignments, portCounters) + + // Assign port on the 'to' device (bidirectional by default) + if (conn.direction !== 'one-way') { + assignPort(conn.to, conn.from, ifaceType, conn.speed, assignments, portCounters) + } + } + + return assignments +} + +function assignPort( + deviceId: string, + connectedTo: string, + ifaceType: string, + speed: string | undefined, + assignments: Map, + portCounters: Map>, +): void { + const deviceAssignments = assignments.get(deviceId) + const counters = portCounters.get(deviceId) + if (!deviceAssignments || !counters) return + + const currentIndex = counters.get(ifaceType) ?? 0 + counters.set(ifaceType, currentIndex + 1) + + deviceAssignments.push({ + deviceId, + interfaceType: ifaceType as 'ethernet' | 'wifi' | 'sfp', + portIndex: currentIndex, + connectedTo, + speed, + }) +} + +/** + * Computes the X position of a specific ethernet/SFP port relative to the card's left edge. + * Used by both the layout engine (for edge routing) and the renderer (for port strip positioning). + */ +export function getPortX(portIndex: number, _totalPorts: number, _cardWidth: number): number { + const startX = PORT_STRIP_PADDING + return startX + portIndex * (PORT_WIDTH + PORT_GAP) + PORT_WIDTH / 2 +} + +/** + * Returns the Y offset of the port strip from the top of the card. + * This must match the renderer's positioning. + */ +export function getPortStripY(cardHeight: number): number { + // Port strip sits near the bottom, above the children row + return cardHeight - 10 +} + +/** Port dimensions exported for the renderer */ +export const PORT_DIMENSIONS = { + width: PORT_WIDTH, + gap: PORT_GAP, + padding: PORT_STRIP_PADDING, +} as const diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index e3fadf7..b00f818 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,179 +1,195 @@ -// ─── YAML Input Types ───────────────────────────────────────────── -// These mirror the user-authored YAML schema. - export interface HomelabDocument { - meta: MetaConfig; - networks?: Network[]; - groups?: Group[]; - devices: Device[]; - connections: Connection[]; + meta: MetaConfig + networks?: Network[] + groups?: Group[] + devices: Device[] + connections: Connection[] } export interface MetaConfig { - title: string; - subtitle?: string; - author?: string; - date?: string; - tags?: string[]; + title: string + subtitle?: string + author?: string + date?: string + tags?: string[] } export interface Network { - id: string; - name: string; - subnet?: string; - dhcp?: DhcpRange; - vlan?: number; + id: string + name: string + subnet?: string + dhcp?: DhcpRange + vlan?: number } export interface DhcpRange { - start: string; - end: string; + start: string + end: string } export interface Group { - id: string; - name: string; - style?: "dashed" | "solid" | "none"; - color?: string; + id: string + name: string + style?: 'dashed' | 'solid' | 'none' + color?: string } export interface Device { - id: string; - name: string; - type: DeviceType | string; - ip?: string; - network?: string; - group?: string; - tags?: string[]; - specs?: DeviceSpecs; - metadata?: Record; - children?: Device[]; - services?: Service[]; + id: string + name: string + type: DeviceType | string + ip?: string + network?: string + group?: string + tags?: string[] + specs?: DeviceSpecs + metadata?: Record + children?: Device[] + services?: Service[] + interfaces?: DeviceInterfaces } /** Well-known device types that receive dedicated icons. */ export type DeviceType = - | "router" - | "switch" - | "firewall" - | "server" - | "hypervisor" - | "vm" - | "container" - | "nas" - | "desktop" - | "laptop" - | "phone" - | "tablet" - | "camera" - | "tv" - | "iot" - | "ap" - | "modem" - | "vpn"; + | 'router' + | 'switch' + | 'firewall' + | 'server' + | 'hypervisor' + | 'vm' + | 'container' + | 'nas' + | 'desktop' + | 'laptop' + | 'phone' + | 'tablet' + | 'camera' + | 'tv' + | 'iot' + | 'ap' + | 'modem' + | 'vpn' + | 'mini-pc' + | 'sbc' + | 'printer' + | 'game-console' + | 'media-player' export interface DeviceSpecs { - cpu?: string; - ram?: string; - storage?: string; - gpu?: string; - os?: string; + cpu?: string + ram?: string + storage?: string + gpu?: string + os?: string +} + +export interface DeviceInterfaces { + ethernet?: InterfaceGroup + wifi?: WifiInterface + sfp?: InterfaceGroup + usb?: InterfaceGroup + thunderbolt?: InterfaceGroup +} + +export interface InterfaceGroup { + count: number + speed?: string +} + +export interface WifiInterface { + bands?: string[] + standard?: string } export interface Service { - name: string; - port?: number; - runtime?: "native" | "docker" | "podman" | string; - url?: string; - metadata?: Record; + name: string + port?: number + runtime?: 'native' | 'docker' | 'podman' | string + url?: string + metadata?: Record } export interface Connection { - from: string; - to: string; - type?: ConnectionType | string; - speed?: string; - direction?: "one-way" | "bidirectional"; - label?: string; -} - -export type ConnectionType = - | "ethernet" - | "wifi" - | "vpn" - | "usb" - | "thunderbolt" - | "fiber"; + from: string + to: string + type?: ConnectionType | string + speed?: string + direction?: 'one-way' | 'bidirectional' + label?: string +} + +export type ConnectionType = 'ethernet' | 'wifi' | 'vpn' | 'usb' | 'thunderbolt' | 'fiber' // ─── Layout Output Types ────────────────────────────────────────── // Produced by the layout engine, consumed by the renderer. export interface PositionedGraph { - nodes: PositionedNode[]; - edges: PositionedEdge[]; - groups: PositionedGroup[]; - bounds: Bounds; - meta: MetaConfig; + nodes: PositionedNode[] + edges: PositionedEdge[] + groups: PositionedGroup[] + bounds: Bounds + meta: MetaConfig + portAssignments: Map } export interface PositionedNode { - device: Device; - x: number; - y: number; - width: number; - height: number; - depth: number; + device: Device + x: number + y: number + width: number + height: number + depth: number } export interface PositionedEdge { - connection: Connection; - points: Point[]; - fromNodeId: string; - toNodeId: string; + connection: Connection + points: Point[] + fromNodeId: string + toNodeId: string + fromPortIndex?: number + toPortIndex?: number } export interface PositionedGroup { - group: Group; - x: number; - y: number; - width: number; - height: number; + group: Group + x: number + y: number + width: number + height: number } export interface Point { - x: number; - y: number; + x: number + y: number } export interface Bounds { - width: number; - height: number; + width: number + height: number } // ─── Validation ─────────────────────────────────────────────────── export interface ValidationError { - path: string; - message: string; - severity: "error" | "warning"; + path: string + message: string + severity: 'error' | 'warning' } // ─── Layout Configuration ───────────────────────────────────────── export interface LayoutOptions { - nodeWidth?: number; - nodeHeight?: number; - horizontalSpacing?: number; - verticalSpacing?: number; - groupPadding?: number; - expanded?: Set; + nodeWidth?: number + nodeHeight?: number + horizontalSpacing?: number + verticalSpacing?: number + groupPadding?: number } export const DEFAULT_LAYOUT_OPTIONS: Required = { - nodeWidth: 200, - nodeHeight: 100, - horizontalSpacing: 60, + nodeWidth: 300, + nodeHeight: 160, + horizontalSpacing: 50, verticalSpacing: 80, groupPadding: 40, - expanded: new Set() -}; +} diff --git a/packages/core/src/validator.ts b/packages/core/src/validator.ts index a7b1cdc..d678bba 100644 --- a/packages/core/src/validator.ts +++ b/packages/core/src/validator.ts @@ -1,139 +1,162 @@ -import type { - HomelabDocument, - Device, - Connection, - ValidationError, -} from "./types"; +import type { HomelabDocument, Device, Connection, ValidationError } from './types' /** * Validates a parsed HomelabDocument for structural and referential integrity. * Returns an empty array when the document is valid. */ export function validate(doc: HomelabDocument): ValidationError[] { - const errors: ValidationError[] = []; + const errors: ValidationError[] = [] - validateMeta(doc, errors); - validateDevices(doc, errors); - validateConnections(doc, errors); - validateReferences(doc, errors); + validateMeta(doc, errors) + validateDevices(doc, errors) + validateConnections(doc, errors) + validateReferences(doc, errors) - return errors; + return errors } // ─── Section validators ─────────────────────────────────────────── function validateMeta(doc: HomelabDocument, errors: ValidationError[]): void { if (!doc.meta) { - errors.push({ path: "meta", message: "Missing required 'meta' section.", severity: "error" }); - return; + errors.push({ path: 'meta', message: "Missing required 'meta' section.", severity: 'error' }) + return } - if (!doc.meta.title || typeof doc.meta.title !== "string") { - errors.push({ path: "meta.title", message: "meta.title is required and must be a string.", severity: "error" }); + if (!doc.meta.title || typeof doc.meta.title !== 'string') { + errors.push({ + path: 'meta.title', + message: 'meta.title is required and must be a string.', + severity: 'error', + }) } } function validateDevices(doc: HomelabDocument, errors: ValidationError[]): void { if (!Array.isArray(doc.devices) || doc.devices.length === 0) { - errors.push({ path: "devices", message: "At least one device is required.", severity: "error" }); - return; + errors.push({ path: 'devices', message: 'At least one device is required.', severity: 'error' }) + return } - const seenIds = new Set(); + const seenIds = new Set() const walkDevices = (devices: Device[], parentPath: string) => { devices.forEach((device, i) => { - const path = `${parentPath}[${i}]`; + const path = `${parentPath}[${i}]` if (!device.id) { - errors.push({ path: `${path}.id`, message: "Device is missing an 'id'.", severity: "error" }); + errors.push({ + path: `${path}.id`, + message: "Device is missing an 'id'.", + severity: 'error', + }) } else if (seenIds.has(device.id)) { - errors.push({ path: `${path}.id`, message: `Duplicate device id '${device.id}'.`, severity: "error" }); + errors.push({ + path: `${path}.id`, + message: `Duplicate device id '${device.id}'.`, + severity: 'error', + }) } else { - seenIds.add(device.id); + seenIds.add(device.id) } if (!device.name) { - errors.push({ path: `${path}.name`, message: "Device is missing a 'name'.", severity: "error" }); + errors.push({ + path: `${path}.name`, + message: "Device is missing a 'name'.", + severity: 'error', + }) } if (!device.type) { - errors.push({ path: `${path}.type`, message: "Device is missing a 'type'.", severity: "warning" }); + errors.push({ + path: `${path}.type`, + message: "Device is missing a 'type'.", + severity: 'warning', + }) } if (device.children && Array.isArray(device.children)) { - walkDevices(device.children, `${path}.children`); + walkDevices(device.children, `${path}.children`) } - }); - }; + }) + } - walkDevices(doc.devices, "devices"); + walkDevices(doc.devices, 'devices') } function validateConnections(doc: HomelabDocument, errors: ValidationError[]): void { - if (!doc.connections) return; + if (!doc.connections) return if (!Array.isArray(doc.connections)) { - errors.push({ path: "connections", message: "'connections' must be an array.", severity: "error" }); - return; + errors.push({ + path: 'connections', + message: "'connections' must be an array.", + severity: 'error', + }) + return } doc.connections.forEach((conn: Connection, i: number) => { - const path = `connections[${i}]`; + const path = `connections[${i}]` if (!conn.from) { - errors.push({ path: `${path}.from`, message: "Connection is missing 'from'.", severity: "error" }); + errors.push({ + path: `${path}.from`, + message: "Connection is missing 'from'.", + severity: 'error', + }) } if (!conn.to) { - errors.push({ path: `${path}.to`, message: "Connection is missing 'to'.", severity: "error" }); + errors.push({ path: `${path}.to`, message: "Connection is missing 'to'.", severity: 'error' }) } - }); + }) } /** Cross-reference check: do connection endpoints point to real device ids? */ function validateReferences(doc: HomelabDocument, errors: ValidationError[]): void { - const deviceIds = new Set(); + const deviceIds = new Set() const collectIds = (devices: Device[]) => { for (const d of devices) { - if (d.id) deviceIds.add(d.id); - if (d.children) collectIds(d.children); + if (d.id) deviceIds.add(d.id) + if (d.children) collectIds(d.children) } - }; - if (Array.isArray(doc.devices)) collectIds(doc.devices); + } + if (Array.isArray(doc.devices)) collectIds(doc.devices) - const networkIds = new Set((doc.networks ?? []).map((n) => n.id)); - const groupIds = new Set((doc.groups ?? []).map((g) => g.id)); - const connections = Array.isArray(doc.connections) ? doc.connections : []; - const devices = Array.isArray(doc.devices) ? doc.devices : []; + const networkIds = new Set((doc.networks ?? []).map((n) => n.id)) + const groupIds = new Set((doc.groups ?? []).map((g) => g.id)) + const connections = Array.isArray(doc.connections) ? doc.connections : [] + const devices = Array.isArray(doc.devices) ? doc.devices : [] connections.forEach((conn, i) => { if (conn.from && !deviceIds.has(conn.from)) { errors.push({ path: `connections[${i}].from`, message: `'${conn.from}' does not match any device id.`, - severity: "error", - }); + severity: 'error', + }) } if (conn.to && !deviceIds.has(conn.to)) { errors.push({ path: `connections[${i}].to`, message: `'${conn.to}' does not match any device id.`, - severity: "error", - }); + severity: 'error', + }) } - }); + }) devices.forEach((device, i) => { if (device.network && !networkIds.has(device.network)) { errors.push({ path: `devices[${i}].network`, message: `Network '${device.network}' is not defined in networks.`, - severity: "warning", - }); + severity: 'warning', + }) } if (device.group && !groupIds.has(device.group)) { errors.push({ path: `devices[${i}].group`, message: `Group '${device.group}' is not defined in groups.`, - severity: "warning", - }); + severity: 'warning', + }) } - }); + }) } diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index ade5bd3..386704d 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -1,11 +1,9 @@ -import {} from 'vitest/config'; - export default { test: { - include: ["__tests__/**/*.test.ts"], - environment: "node", + include: ['__tests__/**/*.test.ts'], + environment: 'node', typecheck: { - tsconfig: "./tsconfig.test.json", + tsconfig: './tsconfig.test.json', }, }, -}; +} diff --git a/packages/renderer/src/components/CanvasControls.tsx b/packages/renderer/src/components/CanvasControls.tsx index c8af37f..0e38a12 100644 --- a/packages/renderer/src/components/CanvasControls.tsx +++ b/packages/renderer/src/components/CanvasControls.tsx @@ -1,38 +1,37 @@ - -import React from "react"; -import { colors, fonts } from "../theme"; +import React from 'react' +import { colors, fonts } from '../theme' interface CanvasControlsProps { - onZoomIn: () => void; - onZoomOut: () => void; - onFitToScreen: () => void; - onResetView: () => void; - scale: number; + onZoomIn: () => void + onZoomOut: () => void + onFitToScreen: () => void + onResetView: () => void + scale: number } const buttonStyle: React.CSSProperties = { width: 36, height: 36, - display: "flex", - alignItems: "center", - justifyContent: "center", - background: "rgba(12, 21, 39, 0.9)", + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + background: 'rgba(12, 21, 39, 0.9)', border: `1px solid ${colors.border}`, borderRadius: 6, color: colors.textSecondary, - cursor: "pointer", - transition: "background 0.15s, color 0.15s, border-color 0.15s", + cursor: 'pointer', + transition: 'background 0.15s, color 0.15s, border-color 0.15s', fontFamily: fonts.mono, fontSize: 14, padding: 0, -}; +} const Button: React.FC<{ - onClick: () => void; - title: string; - children: React.ReactNode; + onClick: () => void + title: string + children: React.ReactNode }> = ({ onClick, title, children }) => { - const [hovered, setHovered] = React.useState(false); + const [hovered, setHovered] = React.useState(false) return ( - ); -}; + ) +} export const CanvasControls: React.FC = ({ onZoomIn, @@ -64,14 +61,14 @@ export const CanvasControls: React.FC = ({ return (
- ); -}; + ) +} diff --git a/packages/renderer/src/components/ConnectionLine.tsx b/packages/renderer/src/components/ConnectionLine.tsx index ba2926d..08f5909 100644 --- a/packages/renderer/src/components/ConnectionLine.tsx +++ b/packages/renderer/src/components/ConnectionLine.tsx @@ -1,43 +1,39 @@ -import React from "react"; -import { connectionColors, colors } from "../theme"; -import type { PositionedEdge } from "@homelab-stackdoc/core"; +import React from 'react' +import { connectionColors, colors } from '../theme' +import type { PositionedEdge } from '@homelab-stackdoc/core' interface ConnectionLineProps { - edge: PositionedEdge; + edge: PositionedEdge + highlighted?: boolean + dimmed?: boolean } -// Unique ID counter for SVG gradient/animation references -let idCounter = 0; +export const ConnectionLine: React.FC = ({ edge, highlighted, dimmed }) => { + const { connection, points } = edge + if (points.length < 2) return null -export const ConnectionLine: React.FC = ({ edge }) => { - const { connection, points } = edge; - if (points.length < 2) return null; - - const connType = connection.type ?? "default"; - const color = connectionColors[connType] ?? connectionColors.default; - const isVpn = connType === "vpn"; - const isWifi = connType === "wifi"; - const animId = `flow-${idCounter++}`; + const connType = connection.type ?? 'default' + const color = connectionColors[connType] ?? connectionColors.default + const isVpn = connType === 'vpn' + const isWifi = connType === 'wifi' // Build SVG path - const d = points - .map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`) - .join(" "); + const d = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ') // Compute total path length for animation const totalLength = points.reduce((sum, p, i) => { - if (i === 0) return 0; - const prev = points[i - 1]; - return sum + Math.hypot(p.x - prev.x, p.y - prev.y); - }, 0); + if (i === 0) return 0 + const prev = points[i - 1] + return sum + Math.hypot(p.x - prev.x, p.y - prev.y) + }, 0) // Animation speed: pixels per second - const speed = 60; - const duration = Math.max(1, totalLength / speed); + const speed = 60 + const duration = Math.max(1, totalLength / speed) // Dash pattern for the animated "particle" layer - const particleGap = 24; - const particleDot = 6; + const particleGap = 24 + const particleDot = 6 return ( @@ -46,10 +42,11 @@ export const ConnectionLine: React.FC = ({ edge }) => { d={d} fill="none" stroke={color} - strokeWidth={5} - strokeOpacity={0.06} + strokeWidth={highlighted ? 8 : 5} + strokeOpacity={highlighted ? 0.15 : dimmed ? 0.02 : 0.06} strokeLinecap="round" strokeLinejoin="round" + style={{ transition: 'stroke-opacity 0.2s, stroke-width 0.2s' }} /> {/* Base line — static, subtle */} @@ -57,23 +54,25 @@ export const ConnectionLine: React.FC = ({ edge }) => { d={d} fill="none" stroke={color} - strokeWidth={1} - strokeOpacity={0.2} + strokeWidth={highlighted ? 2 : 1} + strokeOpacity={highlighted ? 0.5 : dimmed ? 0.08 : 0.2} strokeLinecap="round" strokeLinejoin="round" - strokeDasharray={isVpn ? "6 4" : isWifi ? "2 4" : "none"} + strokeDasharray={isVpn ? '6 4' : isWifi ? '2 4' : 'none'} + style={{ transition: 'stroke-opacity 0.2s, stroke-width 0.2s' }} /> - {/* Animated flow layer — marching dots show direction */} + {/* Animated flow layer — marching dots */} = ({ edge }) => { {/* Speed / label */} - {(connection.speed || connection.label) && points.length >= 2 && (() => { - const mid = points.length >= 4 - ? { x: (points[1].x + points[2].x) / 2, y: (points[1].y + points[2].y) / 2 } - : { x: (points[0].x + points[1].x) / 2, y: (points[0].y + points[1].y) / 2 }; - return ( - - - - {connection.label ?? connection.speed} - - - ); - })()} + {(connection.speed || connection.label) && + points.length >= 2 && + (() => { + const mid = + points.length >= 4 + ? { x: (points[1].x + points[2].x) / 2, y: (points[1].y + points[2].y) / 2 } + : { x: (points[0].x + points[1].x) / 2, y: (points[0].y + points[1].y) / 2 } + return ( + + + + {connection.label ?? connection.speed} + + + ) + })()} - ); -}; + ) +} diff --git a/packages/renderer/src/components/DetailModal.tsx b/packages/renderer/src/components/DetailModal.tsx new file mode 100644 index 0000000..8cb25fd --- /dev/null +++ b/packages/renderer/src/components/DetailModal.tsx @@ -0,0 +1,259 @@ +import React from 'react' +import { colors, fonts, deviceAccent } from '../theme' +import { getDeviceIconPath, getSpecIconPath } from '../icons' +import { ServiceIcon } from './ServiceIcon' +import type { Device, Connection } from '@homelab-stackdoc/core' + +interface DetailModalProps { + child: Device + parent: Device + connections: Connection[] + onClose: () => void +} + +const Tag: React.FC<{ label: string; accent: string }> = ({ label, accent }) => ( + + {label} + +) + +export const DetailModal: React.FC = ({ + child, + parent, + connections, + onClose, +}) => { + const accent = deviceAccent(child.type) + const specs = child.specs ? Object.entries(child.specs).filter(([, v]) => v) : [] + const services = child.services ?? [] + const tags = child.tags ?? [] + + // Find connections involving this child + const childConns = connections.filter((c) => c.from === child.id || c.to === child.id) + + return ( +
+
e.stopPropagation()} + style={{ + width: 420, + maxHeight: '80vh', + overflowY: 'auto', + background: colors.backgroundSubtle, + border: `1px solid ${accent}44`, + borderRadius: 8, + overflow: 'hidden', + boxShadow: `0 0 40px ${accent}22`, + }} + > + {/* Top accent bar */} +
+ +
+ {/* Header */} +
+
+ + + +
+
+
+ {child.name} +
+ {child.ip && ( +
{child.ip}
+ )} +
+ + +
+ + {/* Parent info */} +
+ Hosted on + {parent.name} +
+ + {/* Tags */} + {tags.length > 0 && ( +
+ {tags.map((t) => ( + + ))} +
+ )} + + {/* Specs */} + {specs.length > 0 && ( +
+ {specs.map(([key, value]) => ( +
+ + + + {value} +
+ ))} +
+ )} + + {/* Services */} + {services.length > 0 && ( +
+
+ Services +
+
+ {services.map((svc) => ( +
+ + + {svc.name} + + {svc.port && ( + :{svc.port} + )} + {svc.runtime && ( + + {svc.runtime} + + )} +
+ ))} +
+
+ )} + + {/* Connections */} + {childConns.length > 0 && ( +
+
+ Connections +
+
+ {childConns.map((conn, i) => { + const target = conn.from === child.id ? conn.to : conn.from + const dir = conn.from === child.id ? '→' : '←' + return ( +
+ {dir} {target} + {conn.type && ({conn.type})} +
+ ) + })} +
+
+ )} +
+
+
+ ) +} diff --git a/packages/renderer/src/components/DeviceCard.tsx b/packages/renderer/src/components/DeviceCard.tsx index 6182bf2..689ef5e 100644 --- a/packages/renderer/src/components/DeviceCard.tsx +++ b/packages/renderer/src/components/DeviceCard.tsx @@ -1,59 +1,148 @@ -import React, { useState } from "react"; -import { colors, fonts, deviceAccent } from "../theme"; -import { getIconPath } from "../icons"; -import type { PositionedNode } from "@homelab-stackdoc/core"; +import React, { useState } from 'react' +import { colors, fonts, deviceAccent } from '../theme' +import { getDeviceIconPath, getSpecIconPath } from '../icons' +import { PortStrip } from './PortStrip' +import type { PositionedNode, Device, PortAssignment } from '@homelab-stackdoc/core' interface DeviceCardProps { - node: PositionedNode; - isExpanded: boolean; - hasChildren: boolean; - onToggleExpand: (id: string) => void; + node: PositionedNode + originalDevice: Device + onChildClick: (child: Device, parent: Device) => void + portAssignments: PortAssignment[] + onPortHover?: (deviceId: string, connectedTo: string | null) => void +} + +const SpecItem: React.FC<{ specKey: string; value: string }> = ({ specKey, value }) => ( +
+ + + + {value} +
+) + +const Tag: React.FC<{ label: string; accent: string }> = ({ label, accent }) => ( + + {label} + +) + +const ChildCircle: React.FC<{ + child: Device + onClick: () => void +}> = ({ child, onClick }) => { + const [hovered, setHovered] = useState(false) + const accent = deviceAccent(child.type) + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + onClick={(e) => { + e.stopPropagation() + onClick() + }} + style={{ + position: 'relative', + width: 30, + height: 30, + borderRadius: '50%', + background: `${accent}15`, + border: `1.5px solid ${hovered ? accent : `${accent}44`}`, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + transition: 'all 0.15s', + boxShadow: hovered ? `0 0 12px ${accent}33` : 'none', + flexShrink: 0, + }} + > + + + + {hovered && ( +
+ {child.name} +
+ )} +
+ ) } export const DeviceCard: React.FC = ({ node, - isExpanded, - hasChildren, - onToggleExpand, + originalDevice, + onChildClick, + portAssignments, + onPortHover, }) => { - const [hovered, setHovered] = useState(false); - const { device, x, y, width, height } = node; - const accent = deviceAccent(device.type); + const [hovered, setHovered] = useState(false) + const { device, x, y, width, height } = node + const accent = deviceAccent(device.type) - const specs = device.specs - ? Object.entries(device.specs).filter(([, v]) => v) - : []; - const tags = device.tags ?? []; - const services = device.services ?? []; + const specs = originalDevice.specs + ? Object.entries(originalDevice.specs).filter(([, v]) => v) + : [] + const tags = originalDevice.tags ?? [] + const children = originalDevice.children ?? [] return (
setHovered(true)} onMouseLeave={() => setHovered(false)} style={{ - position: "absolute", + position: 'absolute', left: x, top: y, width, - height, - overflow: "hidden", - background: hovered ? "rgba(0,229,255,0.06)" : colors.backgroundSubtle, + minHeight: height, + background: colors.backgroundSubtle, borderRadius: 6, + overflow: 'visible', + border: `1px solid ${hovered ? accent : colors.border}`, fontFamily: fonts.mono, - cursor: hasChildren ? "pointer" : "default", - transition: "border-color 0.2s, background 0.2s, box-shadow 0.2s", - boxShadow: hovered - ? `0 0 20px ${accent}22, inset 0 0 20px ${accent}08` - : "none", - boxSizing: "border-box", - display: "flex", - flexDirection: "row", - }} - onClick={(e) => { - if (hasChildren) { - e.stopPropagation(); - onToggleExpand(device.id); - } + cursor: 'default', + transition: 'border-color 0.2s, box-shadow 0.2s', + boxShadow: hovered ? `0 0 24px ${accent}22` : 'none', + display: 'flex', }} > {/* Left accent bar */} @@ -62,220 +151,146 @@ export const DeviceCard: React.FC = ({ width: 3, flexShrink: 0, background: accent, - borderRadius: "6px 0 0 6px", opacity: hovered ? 1 : 0.6, - transition: "opacity 0.2s", + transition: 'opacity 0.2s', }} /> - {/* Content area */}
- {/* Header: icon + name + expand indicator */} -
- - + {/* Header: icon + name + IP + type badge */} +
+ + -
-
- {device.name} -
- {device.ip && ( -
- {device.ip} -
- )} -
- {/* Expand/collapse chevron */} - {hasChildren && ( - - - + + {device.name} + + {device.ip && ( + + {device.ip} + )} +
+
{/* Tags */} {tags.length > 0 && ( -
- {tags.map((tag) => ( - - {tag} - +
+ {tags.map((t) => ( + ))}
)} - {/* Specs — compact */} + {/* Specs — icon + value pairs */} {specs.length > 0 && ( -
+
{specs.map(([key, value]) => ( -
- - {key} - {" "} - {value} -
+ ))}
)} - {/* Services list (visible for leaf nodes or expanded parents) */} - {services.length > 0 && ( -
- {services.map((svc) => ( + {/* Port strip */} + {originalDevice.interfaces && ( + onPortHover?.(device.id, connectedTo)} + /> + )} + + {/* Children — horizontal row with overflow indicator */} + {children.length > 0 && + (() => { + const maxVisible = Math.min( + children.length, + Math.max(2, Math.floor((width - 100) / 36)), + ) + const visible = children.slice(0, maxVisible) + const overflow = children.length - maxVisible + + return (
- {svc.name} - {svc.port && ( - :{svc.port} - )} - {svc.runtime && ( - - {svc.runtime} - - )} + > + {children.length} + +
+ {visible.map((c) => ( + onChildClick(c, originalDevice)} + /> + ))} + {overflow > 0 && ( +
{ + e.stopPropagation() + onChildClick(children[maxVisible], originalDevice) + }} + style={{ + width: 30, + height: 30, + borderRadius: '50%', + background: `${colors.textMuted}15`, + border: `1.5px solid ${colors.textMuted}33`, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + fontSize: 9, + color: colors.textMuted, + fontWeight: 700, + fontFamily: fonts.mono, + flexShrink: 0, + }} + > + +{overflow} +
+ )} +
- ))} -
- )} - - {/* Collapsed children count badge */} - {hasChildren && !isExpanded && ( -
- - - - click to expand -
- )} + ) + })()}
- ); -}; + ) +} diff --git a/packages/renderer/src/components/GroupOutline.tsx b/packages/renderer/src/components/GroupOutline.tsx index 2377e41..3347da0 100644 --- a/packages/renderer/src/components/GroupOutline.tsx +++ b/packages/renderer/src/components/GroupOutline.tsx @@ -1,20 +1,43 @@ -import React from "react"; -import { colors, fonts } from "../theme"; -import type { PositionedGroup } from "@homelab-stackdoc/core"; +import React from 'react' +import { colors, fonts } from '../theme' +import type { PositionedGroup } from '@homelab-stackdoc/core' interface GroupOutlineProps { - group: PositionedGroup; + group: PositionedGroup } export const GroupOutline: React.FC = ({ group }) => { - const { x, y, width, height } = group; - const style = group.group.style ?? "dashed"; - const accentColor = group.group.color ?? colors.primary; + const { x, y, width, height } = group + const style = group.group.style ?? 'dashed' + const accentColor = group.group.color ?? colors.primary - if (style === "none") return null; + if (style === 'none') return null + + const label = group.group.name.toUpperCase() + const labelFontSize = 9 + const labelPadX = 8 + const labelPadY = 3 + const labelX = x + 12 + const labelY = y + 16 + + // Approximate label width (monospace ~5.5px per char at 9px font) + const approxLabelWidth = label.length * 5.5 + labelPadX * 2 return ( + {/* Background fill */} + + + {/* Border */} = ({ group }) => { height={height} rx={8} ry={8} - fill={`${accentColor}06`} + fill="none" stroke={accentColor} strokeWidth={1} - strokeOpacity={0.3} - strokeDasharray={style === "dashed" ? "8 4" : "none"} + strokeOpacity={0.2} + strokeDasharray={style === 'dashed' ? '6 4' : 'none'} + /> + + {/* Label background pill */} + - {/* Group label — positioned at the top edge */} + + {/* Label text */} - {group.group.name.toUpperCase()} + {label} - ); -}; + ) +} diff --git a/packages/renderer/src/components/PortStrip.tsx b/packages/renderer/src/components/PortStrip.tsx new file mode 100644 index 0000000..12feb7c --- /dev/null +++ b/packages/renderer/src/components/PortStrip.tsx @@ -0,0 +1,364 @@ +import React, { useState } from 'react' +import { colors, fonts } from '../theme' +import type { DeviceInterfaces } from '@homelab-stackdoc/core' +import type { PortAssignment } from '@homelab-stackdoc/core' + +interface PortStripProps { + interfaces: DeviceInterfaces + assignments: PortAssignment[] + cardWidth: number + onPortHover?: (connectedTo: string | null) => void +} + +const PORT_W = 18 +const PORT_H = 16 +const PORT_GAP = 3 + +const RJ45Port: React.FC<{ + active: boolean + connectedTo?: string + speed?: string + index: number + onHover?: (connectedTo: string | null) => void +}> = ({ active, connectedTo, speed, index, onHover }) => { + const [hovered, setHovered] = useState(false) + const activeColor = colors.green + + const handleEnter = () => { + setHovered(true) + if (active && connectedTo && onHover) onHover(connectedTo) + } + + const handleLeave = () => { + setHovered(false) + if (onHover) onHover(null) + } + + return ( +
+ + {/* RJ45 housing */} + + {/* Top clip/latch */} + + {/* Latch notch */} + + {/* Pin contacts (4 pins) */} + {[5, 7.5, 10, 12.5].map((px, i) => ( + + ))} + {/* Active LED indicator */} + {active && ( + <> + + {hovered && } + + )} + + + {/* Port number */} +
+ {index + 1} +
+ + {/* Tooltip — rendered below the port to avoid clipping */} + {hovered && ( +
+ {active + ? `Port ${index + 1} → ${connectedTo}${speed ? ` (${speed})` : ''}` + : `Port ${index + 1} · Empty`} +
+ )} +
+ ) +} + +const WifiIndicator: React.FC<{ + clientCount: number + bands?: string[] + assignments: PortAssignment[] + onHover?: (connectedTo: string | null) => void +}> = ({ clientCount, bands, assignments, onHover }) => { + const [hovered, setHovered] = useState(false) + const active = clientCount > 0 + + const handleEnter = () => { + setHovered(true) + // Highlight all wifi connections + if (active && onHover && assignments.length > 0) { + onHover(assignments[0].connectedTo) + } + } + + const handleLeave = () => { + setHovered(false) + if (onHover) onHover(null) + } + + return ( +
+ + + + {active && ( + + {clientCount} + + )} + + {hovered && ( +
+
+ WiFi · {clientCount} client{clientCount !== 1 ? 's' : ''} +
+ {bands && bands.length > 0 && ( +
{bands.join(' / ')}
+ )} + {assignments.length > 0 && ( +
+ {assignments.map((a, i) => ( +
+ → {a.connectedTo} +
+ ))} +
+ )} +
+ )} +
+ ) +} + +export const PortStrip: React.FC = ({ + interfaces, + assignments, + cardWidth: _cardWidth, + onPortHover, +}) => { + const ethCount = interfaces.ethernet?.count ?? 0 + const sfpCount = interfaces.sfp?.count ?? 0 + const hasWifi = !!interfaces.wifi + + const ethAssignments = assignments.filter((a) => a.interfaceType === 'ethernet') + const sfpAssignments = assignments.filter((a) => a.interfaceType === 'sfp') + const wifiAssignments = assignments.filter((a) => a.interfaceType === 'wifi') + + if (ethCount === 0 && sfpCount === 0 && !hasWifi) return null + + return ( +
+ {/* Ethernet ports */} + {ethCount > 0 && ( +
+
+ {Array.from({ length: ethCount }, (_, i) => { + const assignment = ethAssignments.find((a) => a.portIndex === i) + return ( + + ) + })} +
+
+ {interfaces.ethernet?.speed ? `${interfaces.ethernet.speed} ETH` : 'ETHERNET'} +
+
+ )} + + {/* SFP ports */} + {sfpCount > 0 && ( +
+
+ {Array.from({ length: sfpCount }, (_, i) => { + const assignment = sfpAssignments.find((a) => a.portIndex === i) + return ( + + ) + })} +
+
+ {interfaces.sfp?.speed ? `${interfaces.sfp.speed} SFP` : 'SFP'} +
+
+ )} + + {/* WiFi indicator */} + {hasWifi && ( +
+ +
+ WIFI +
+
+ )} +
+ ) +} diff --git a/packages/renderer/src/components/ServiceIcon.tsx b/packages/renderer/src/components/ServiceIcon.tsx new file mode 100644 index 0000000..5b48cab --- /dev/null +++ b/packages/renderer/src/components/ServiceIcon.tsx @@ -0,0 +1,93 @@ +import React, { useState } from 'react' +import { colors } from '../theme' + +const CDN_BASE = 'https://cdn.jsdelivr.net/gh/selfhst/icons/svg' + +/** + * Converts a service name to the selfh.st icon reference format. + * "AdGuard Home" → "adguard-home" + * "Pi-hole" → "pi-hole" + * "qBittorrent" → "qbittorrent" + */ +function toIconRef(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') +} + +interface ServiceIconProps { + name: string + size?: number + fallbackColor?: string +} + +export const ServiceIcon: React.FC = ({ + name, + size = 18, + fallbackColor = colors.green, +}) => { + const ref = toIconRef(name) + const [src, setSrc] = useState(`${CDN_BASE}/${ref}.svg`) + const [failed, setFailed] = useState(false) + const [triedLight, setTriedLight] = useState(false) + + const handleError = () => { + if (!triedLight) { + // Try the light variant for dark backgrounds + setTriedLight(true) + setSrc(`${CDN_BASE}/${ref}-light.svg`) + } else { + // Both failed — show fallback + setFailed(true) + } + } + + if (failed) { + // Fallback: coloured dot with first letter + return ( +
+ {name.charAt(0).toUpperCase()} +
+ ) + } + + return ( + {name} + ) +} + +/** + * Hook to get the icon URL for a service name. + * Useful when you need just the URL, not the component. + */ +export function getServiceIconUrl(name: string): string { + return `${CDN_BASE}/${toIconRef(name)}.svg` +} diff --git a/packages/renderer/src/components/TopologyCanvas.tsx b/packages/renderer/src/components/TopologyCanvas.tsx index cb7cdd0..a5c638b 100644 --- a/packages/renderer/src/components/TopologyCanvas.tsx +++ b/packages/renderer/src/components/TopologyCanvas.tsx @@ -1,165 +1,160 @@ -import React, { useRef, useState, useCallback, useEffect } from "react"; +import React, { useRef, useState, useCallback, useEffect } from 'react' import { CanvasControls } from './CanvasControls' -import { colors, fonts } from "../theme"; -import { ConnectionLine } from "./ConnectionLine"; -import { DeviceCard } from "./DeviceCard"; -import { GroupOutline } from "./GroupOutline"; -import type { PositionedGraph, Device } from "@homelab-stackdoc/core"; +import { colors, fonts } from '../theme' +import { ConnectionLine } from './ConnectionLine' +import { DetailModal } from './DetailModal' +import { DeviceCard } from './DeviceCard' +import { GroupOutline } from './GroupOutline' +import type { PositionedGraph, Device, Connection } from '@homelab-stackdoc/core' interface TopologyCanvasProps { - graph: PositionedGraph; - expanded: Set; - onToggleExpand: (id: string) => void; - /** Map of device id → original device with children, for checking hasChildren */ - deviceMap: Map; + graph: PositionedGraph + deviceMap: Map + connections: Connection[] } interface Transform { - x: number; - y: number; - scale: number; + x: number + y: number + scale: number } export const TopologyCanvas: React.FC = ({ graph, - expanded, - onToggleExpand, deviceMap, + connections, }) => { - const containerRef = useRef(null); - const [transform, setTransform] = useState({ - x: 0, - y: 0, - scale: 1, - }); - const [dragging, setDragging] = useState(false); - const dragStart = useRef({ x: 0, y: 0, tx: 0, ty: 0 }); + const containerRef = useRef(null) + const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 }) + const [dragging, setDragging] = useState(false) + const dragStart = useRef({ x: 0, y: 0, tx: 0, ty: 0 }) - // Auto-fit on graph change - useEffect(() => { - if (!containerRef.current) return; - const rect = containerRef.current.getBoundingClientRect(); + const [modalChild, setModalChild] = useState(null) + const [modalParent, setModalParent] = useState(null) - const headerHeight = 44; - const padding = 40; - const availableWidth = rect.width - padding * 2; - const availableHeight = rect.height - headerHeight - padding * 2; + const [highlightedEdge, setHighlightedEdge] = useState<{ from: string; to: string } | null>(null) - const scaleX = availableWidth / graph.bounds.width; - const scaleY = availableHeight / graph.bounds.height; - const scale = Math.min(scaleX, scaleY, 1); + const handleChildClick = useCallback((child: Device, parent: Device) => { + setModalChild(child) + setModalParent(parent) + }, []) - const scaledWidth = graph.bounds.width * scale; - const scaledHeight = graph.bounds.height * scale; - const x = (rect.width - scaledWidth) / 2; - const y = headerHeight + (availableHeight - scaledHeight) / 2 + padding; + const closeModal = useCallback(() => { + setModalChild(null) + setModalParent(null) + }, []) - setTransform({ x, y, scale }); - }, [graph]); + const handlePortHover = useCallback((deviceId: string, connectedTo: string | null) => { + if (connectedTo) { + setHighlightedEdge({ from: deviceId, to: connectedTo }) + } else { + setHighlightedEdge(null) + } + }, []) - // Non-passive wheel zoom + // Auto-fit on graph change useEffect(() => { - const el = containerRef.current; - if (!el) return; + if (!containerRef.current) return + const rect = containerRef.current.getBoundingClientRect() + const headerHeight = 44 + const padding = 40 + const availableWidth = rect.width - padding * 2 + const availableHeight = rect.height - headerHeight - padding * 2 + const scaleX = availableWidth / graph.bounds.width + const scaleY = availableHeight / graph.bounds.height + const scale = Math.min(scaleX, scaleY, 1) + const scaledWidth = graph.bounds.width * scale + const scaledHeight = graph.bounds.height * scale + const x = (rect.width - scaledWidth) / 2 + const y = headerHeight + (availableHeight - scaledHeight) / 2 + padding + setTransform({ x, y, scale }) + }, [graph]) + // Non-passive wheel zoom + useEffect(() => { + const el = containerRef.current + if (!el) return const handleWheel = (e: WheelEvent) => { - e.preventDefault(); - const delta = e.deltaY > 0 ? 0.92 : 1.08; + e.preventDefault() + const delta = e.deltaY > 0 ? 0.92 : 1.08 setTransform((t) => { - const newScale = Math.min(3, Math.max(0.15, t.scale * delta)); - const rect = el.getBoundingClientRect(); - const cx = e.clientX - rect.left; - const cy = e.clientY - rect.top; + const newScale = Math.min(3, Math.max(0.15, t.scale * delta)) + const rect = el.getBoundingClientRect() + const cx = e.clientX - rect.left + const cy = e.clientY - rect.top return { scale: newScale, x: cx - (cx - t.x) * (newScale / t.scale), y: cy - (cy - t.y) * (newScale / t.scale), - }; - }); - }; - - el.addEventListener("wheel", handleWheel, { passive: false }); - return () => el.removeEventListener("wheel", handleWheel); - }, []); + } + }) + } + el.addEventListener('wheel', handleWheel, { passive: false }) + return () => el.removeEventListener('wheel', handleWheel) + }, []) - // Pan handlers + // Pan const onMouseDown = useCallback( (e: React.MouseEvent) => { - if (e.button !== 0) return; - setDragging(true); - dragStart.current = { - x: e.clientX, - y: e.clientY, - tx: transform.x, - ty: transform.y, - }; + if (e.button !== 0) return + setDragging(true) + dragStart.current = { x: e.clientX, y: e.clientY, tx: transform.x, ty: transform.y } }, [transform], - ); + ) const onMouseMove = useCallback( (e: React.MouseEvent) => { - if (!dragging) return; - const dx = e.clientX - dragStart.current.x; - const dy = e.clientY - dragStart.current.y; + if (!dragging) return setTransform((t) => ({ ...t, - x: dragStart.current.tx + dx, - y: dragStart.current.ty + dy, - })); + x: dragStart.current.tx + (e.clientX - dragStart.current.x), + y: dragStart.current.ty + (e.clientY - dragStart.current.y), + })) }, [dragging], - ); + ) - const onMouseUp = useCallback(() => setDragging(false), []); + const onMouseUp = useCallback(() => setDragging(false), []) + // Zoom controls const onZoomIn = useCallback(() => { - setTransform((t) => ({ - ...t, - scale: Math.min(3, t.scale * 1.2), - })); - }, []); + setTransform((t) => ({ ...t, scale: Math.min(3, t.scale * 1.2) })) + }, []) const onZoomOut = useCallback(() => { - setTransform((t) => ({ - ...t, - scale: Math.max(0.15, t.scale / 1.2), - })); - }, []); + setTransform((t) => ({ ...t, scale: Math.max(0.15, t.scale / 1.2) })) + }, []) const fitToScreen = useCallback(() => { - if (!containerRef.current) return; - const rect = containerRef.current.getBoundingClientRect(); - - const headerHeight = 44; - const padding = 40; - const availableWidth = rect.width - padding * 2; - const availableHeight = rect.height - headerHeight - padding * 2; - - const scaleX = availableWidth / graph.bounds.width; - const scaleY = availableHeight / graph.bounds.height; - const scale = Math.min(scaleX, scaleY, 1); - - const scaledWidth = graph.bounds.width * scale; - const scaledHeight = graph.bounds.height * scale; - const x = (rect.width - scaledWidth) / 2; - const y = headerHeight + (availableHeight - scaledHeight) / 2 + padding; - - setTransform({ x, y, scale }); - }, [graph]); + if (!containerRef.current) return + const rect = containerRef.current.getBoundingClientRect() + const headerHeight = 44 + const padding = 40 + const availableWidth = rect.width - padding * 2 + const availableHeight = rect.height - headerHeight - padding * 2 + const scaleX = availableWidth / graph.bounds.width + const scaleY = availableHeight / graph.bounds.height + const scale = Math.min(scaleX, scaleY, 1) + const scaledWidth = graph.bounds.width * scale + const scaledHeight = graph.bounds.height * scale + const x = (rect.width - scaledWidth) / 2 + const y = headerHeight + (availableHeight - scaledHeight) / 2 + padding + setTransform({ x, y, scale }) + }, [graph]) const resetView = useCallback(() => { - if (!containerRef.current) return; - const rect = containerRef.current.getBoundingClientRect(); - const x = (rect.width - graph.bounds.width) / 2; - setTransform({ x, y: 60, scale: 1 }); - }, [graph]); + if (!containerRef.current) return + const rect = containerRef.current.getBoundingClientRect() + const x = (rect.width - graph.bounds.width) / 2 + setTransform({ x, y: 60, scale: 1 }) + }, [graph]) const legend = [ - { label: "ETHERNET", color: "#00e676", dash: "" }, - { label: "WI-FI", color: "#00e5ff", dash: "2 4" }, - { label: "VPN", color: "#ffab00", dash: "6 4" }, - ]; + { label: 'ETHERNET', color: '#00e676', dash: '' }, + { label: 'WI-FI', color: '#00e5ff', dash: '2 4' }, + { label: 'VPN', color: '#ffab00', dash: '6 4' }, + ] return (
= ({ onMouseUp={onMouseUp} onMouseLeave={onMouseUp} style={{ - width: "100%", - height: "100%", - overflow: "hidden", + width: '100%', + height: '100%', + overflow: 'hidden', background: colors.background, - cursor: dragging ? "grabbing" : "grab", - position: "relative", - userSelect: "none", + cursor: dragging ? 'grabbing' : 'grab', + position: 'relative', + userSelect: 'none', }} > - {/* Header bar */} + {/* Header */}
- + {graph.meta.title} {graph.meta.subtitle && ( - - {graph.meta.subtitle} - + {graph.meta.subtitle} )} {(graph.meta.tags ?? []).map((tag) => ( = ({ style={{ fontSize: 8, fontWeight: 700, - letterSpacing: "0.06em", - textTransform: "uppercase", + letterSpacing: '0.06em', + textTransform: 'uppercase', color: colors.green, background: colors.greenDim, borderRadius: 3, - padding: "2px 8px", + padding: '2px 8px', }} > {tag} ))}
-
+
{legend.map((l) => ( -
+
= ({ y2={2} stroke={l.color} strokeWidth={1.5} - strokeDasharray={l.dash || "none"} + strokeDasharray={l.dash || 'none'} /> @@ -261,7 +245,7 @@ export const TopologyCanvas: React.FC = ({
- {/* Canvas controls */} + {/* Controls */} = ({ scale={transform.scale} /> - {/* Transformed canvas */} + {/* Canvas */}
{graph.groups.map((g) => ( ))} - {graph.edges.map((edge, i) => ( - - ))} + {graph.edges.map((edge, i) => { + const isHighlighted = highlightedEdge + ? (edge.fromNodeId === highlightedEdge.from && + edge.toNodeId === highlightedEdge.to) || + (edge.fromNodeId === highlightedEdge.to && edge.toNodeId === highlightedEdge.from) + : false + return ( + + ) + })} - {graph.nodes.map((node) => { - const original = deviceMap.get(node.device.id); - const hasChildren = - !!original?.children && original.children.length > 0; + const original = deviceMap.get(node.device.id) return ( - ); + ) })}
+ + {/* Modal */} + {modalChild && modalParent && ( + + )}
- ); -}; + ) +} diff --git a/packages/renderer/src/icons.ts b/packages/renderer/src/icons.ts index 3bf15fd..e97da61 100644 --- a/packages/renderer/src/icons.ts +++ b/packages/renderer/src/icons.ts @@ -1,45 +1,65 @@ /** - * Lightweight SVG icon map for well-known device types. - * Each value is an SVG path `d` attribute for a 24×24 viewBox. - * Unknown types fall back to "device" (generic server icon). + * SVG path data for device type icons (24×24 viewBox). Unknown types fall back to "device". */ -const iconPaths: Record = { +export const deviceIconPaths: Record = { router: - "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm-1-13h2v6h-2zm0 8h2v2h-2z", + 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 5h2v6h-2zm0 8h2v2h-2z', switch: - "M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zM4 6h16v3H4V6zm0 5h3v2H4v-2zm0 4h3v2H4v-2zm5-4h3v2H9v-2zm0 4h3v2H9v-2zm5-4h3v2h-3v-2zm0 4h3v2h-3v-2z", + 'M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zM4 6h16v3H4V6zm0 5h3v2H4v-2zm0 4h3v2H4v-2zm5-4h3v2H9v-2zm0 4h3v2H9v-2zm5-4h3v2h-3v-2zm0 4h3v2h-3v-2z', firewall: - "M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z", + 'M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z', server: - "M20 2H4c-1.1 0-2 .9-2 2v4c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 6H4V4h16v4zm0 4H4c-1.1 0-2 .9-2 2v4c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2v-4c0-1.1-.9-2-2-2zm0 6H4v-4h16v4zM6 7h2V5H6v2zm0 8h2v-2H6v2z", + 'M20 2H4c-1.1 0-2 .9-2 2v4c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 6H4V4h16v4zm0 4H4c-1.1 0-2 .9-2 2v4c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2v-4c0-1.1-.9-2-2-2zm0 6H4v-4h16v4zM6 7h2V5H6v2zm0 8h2v-2H6v2z', hypervisor: - "M20 2H4c-1.1 0-2 .9-2 2v4c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 6H4V4h16v4zM4 14h7v-2H4v2zm0 4h7v-2H4v2zm9-4h7v-2h-7v2zm0 4h7v-2h-7v2z", - nas: - "M2 20h20v-4H2v4zm2-3h2v2H4v-2zM2 4v4h20V4H2zm4 3H4V5h2v2zM2 14h20v-4H2v4zm2-3h2v2H4v-2z", - vm: - "M21 2H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h7v2H8v2h8v-2h-2v-2h7c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H3V4h18v12z", + 'M20 2H4c-1.1 0-2 .9-2 2v4c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 6H4V4h16v4zM4 14h7v-2H4v2zm0 4h7v-2H4v2zm9-4h7v-2h-7v2zm0 4h7v-2h-7v2z', + 'mini-pc': + 'M20 2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h4v2H6v2h12v-2h-2v-2h4c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H4V4h16v12z', + sbc: 'M17 3H7c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H7V5h10v14zM8 7h3v2H8V7zm5 0h3v2h-3V7zm-5 4h3v2H8v-2zm5 0h3v2h-3v-2z', + nas: 'M2 20h20v-4H2v4zm2-3h2v2H4v-2zM2 4v4h20V4H2zm4 3H4V5h2v2zM2 14h20v-4H2v4zm2-3h2v2H4v-2z', + vm: 'M21 2H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h7v2H8v2h8v-2h-2v-2h7c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H3V4h18v12z', container: - "M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM5 15h5v3H5zm0-5h5v3H5zm7 5h7v3h-7zm0-5h7v3h-7z", + 'M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM5 15h5v3H5zm0-5h5v3H5zm7 5h7v3h-7zm0-5h7v3h-7z', desktop: - "M21 2H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h7l-2 3v1h8v-1l-2-3h7c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 12H3V4h18v10z", + 'M21 2H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h7l-2 3v1h8v-1l-2-3h7c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 12H3V4h18v10z', laptop: - "M20 18c1.1 0 1.99-.9 1.99-2L22 5c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v11c0 1.1.89 2 2 2H0c0 1.1.89 2 2 2h20c1.1 0 2-.9 2-2h-4zM4 5h16v11H4V5zm-2 15l2-2h16l2 2H2z", + 'M20 18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v11c0 1.1.89 2 2 2H0c0 1.1.89 2 2 2h20c1.1 0 2-.9 2-2h-4zM4 5h16v11H4V5zm-2 15l2-2h16l2 2H2z', phone: - "M15.5 1h-8C6.12 1 5 2.12 5 3.5v17C5 21.88 6.12 23 7.5 23h8c1.38 0 2.5-1.12 2.5-2.5v-17C18 2.12 16.88 1 15.5 1zm-4 21c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm4.5-4H7V4h9v14z", + 'M15.5 1h-8C6.12 1 5 2.12 5 3.5v17C5 21.88 6.12 23 7.5 23h8c1.38 0 2.5-1.12 2.5-2.5v-17C18 2.12 16.88 1 15.5 1zm-4 21c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm4.5-4H7V4h9v14z', tablet: - "M18.5 0h-14C3.12 0 2 1.12 2 2.5v19C2 22.88 3.12 24 4.5 24h14c1.38 0 2.5-1.12 2.5-2.5v-19C21 1.12 19.88 0 18.5 0zm-7 23c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm7.5-4H4V3h15v16z", + 'M18.5 0h-14C3.12 0 2 1.12 2 2.5v19C2 22.88 3.12 24 4.5 24h14c1.38 0 2.5-1.12 2.5-2.5v-19C21 1.12 19.88 0 18.5 0zm-7 23c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm7.5-4H4V3h15v16z', camera: - "M12 10.8a2.2 2.2 0 100 4.4 2.2 2.2 0 000-4.4zM18 4h-3.17L13.41 2.59a2 2 0 00-1.42-.59h-1.98a2 2 0 00-1.42.59L7.17 4H4a2 2 0 00-2 2v12a2 2 0 002 2h16a2 2 0 002-2V6a2 2 0 00-2-2zm-6 13a4.5 4.5 0 110-9 4.5 4.5 0 010 9z", - tv: "M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z", - iot: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z", - ap: "M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.08 2.93 1 9zm8 8l3 3 3-3a4.237 4.237 0 00-6 0zm-4-4l2 2a7.074 7.074 0 0110 0l2-2C15.14 9.14 8.87 9.14 5 13z", + 'M12 10.8a2.2 2.2 0 100 4.4 2.2 2.2 0 000-4.4zM18 4h-3.17L13.41 2.59a2 2 0 00-1.42-.59h-1.98a2 2 0 00-1.42.59L7.17 4H4a2 2 0 00-2 2v12a2 2 0 002 2h16a2 2 0 002-2V6a2 2 0 00-2-2zm-6 13a4.5 4.5 0 110-9 4.5 4.5 0 010 9z', + tv: 'M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z', + iot: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z', + ap: 'M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.08 2.93 1 9zm8 8l3 3 3-3a4.237 4.237 0 00-6 0zm-4-4l2 2a7.074 7.074 0 0110 0l2-2C15.14 9.14 8.87 9.14 5 13z', modem: - "M20 2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM8 20H4v-4h4v4zm0-6H4v-4h4v4zm6 6h-4v-4h4v4zm0-6h-4v-4h4v4zm6 6h-4v-4h4v4zm0-6h-4v-4h4v4z", - vpn: "M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z", + 'M20 2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM8 20H4v-4h4v4zm0-6H4v-4h4v4zm6 6h-4v-4h4v4zm0-6h-4v-4h4v4zm6 6h-4v-4h4v4zm0-6h-4v-4h4v4z', + vpn: 'M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z', + printer: + 'M19 8H5c-1.66 0-3 1.34-3 3v6h4v4h12v-4h4v-6c0-1.66-1.34-3-3-3zm-3 11H8v-5h8v5zm3-7c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1zm-1-9H6v4h12V3z', + 'game-console': + 'M21 6H3c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm-10 7H8v3H6v-3H3v-2h3V8h2v3h3v2zm4.5 2c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm4-3c-.83 0-1.5-.67-1.5-1.5S18.67 9 19.5 9s1.5.67 1.5 1.5-.67 1.5-1.5 1.5z', + 'media-player': 'M8 5v14l11-7z', device: - "M20 2H4c-1.1 0-2 .9-2 2v4c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 6H4V4h16v4zm0 4H4c-1.1 0-2 .9-2 2v4c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2v-4c0-1.1-.9-2-2-2zm0 6H4v-4h16v4z", -}; + 'M20 2H4c-1.1 0-2 .9-2 2v4c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 6H4V4h16v4zm0 4H4c-1.1 0-2 .9-2 2v4c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2v-4c0-1.1-.9-2-2-2zm0 6H4v-4h16v4z', +} + +/** + * SVG path data for spec property icons (24×24 viewBox). + */ +export const specIconPaths: Record = { + cpu: 'M15 9H9v6h6V9zm-2 4h-2v-2h2v2zm8-2V9h-2V7c0-1.1-.9-2-2-2h-2V3h-2v2h-2V3H9v2H7c-1.1 0-2 .9-2 2v2H3v2h2v2H3v2h2v2c0 1.1.9 2 2 2h2v2h2v-2h2v2h2v-2h2c1.1 0 2-.9 2-2v-2h2v-2h-2v-2h2zm-4 6H7V7h10v10z', + ram: 'M2 7v10h20V7H2zm18 8H4V9h16v6zM6 11h2v2H6zm3 0h2v2H9zm3 0h2v2h-2zm3 0h2v2h-2z', + storage: + 'M20 4H4c-1.1 0-2 .9-2 2v3c0 .7.4 1.3 1 1.7V18c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-7.3c.6-.4 1-1 1-1.7V6c0-1.1-.9-2-2-2zm0 5H4V6h16v3zM5 18v-7h14v7H5zm2-1h2v-4H7v4z', + gpu: 'M20 8h-3V6c0-1.1-.9-2-2-2H9c-1.1 0-2 .9-2 2v2H4c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2v-8c0-1.1-.9-2-2-2zM9 6h6v2H9V6zm11 12H4v-8h16v8zm-7-2h4v-4h-4v4z', + os: 'M20 18c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2H0v2h24v-2h-4zM4 6h16v10H4V6z', +} + +export function getDeviceIconPath(type: string): string { + return deviceIconPaths[type] ?? deviceIconPaths.device +} -export function getIconPath(type: string): string { - return iconPaths[type] ?? iconPaths.device; +export function getSpecIconPath(key: string): string { + return specIconPaths[key] ?? specIconPaths.os } diff --git a/packages/renderer/src/index.ts b/packages/renderer/src/index.ts index a58ac93..ece4832 100644 --- a/packages/renderer/src/index.ts +++ b/packages/renderer/src/index.ts @@ -1,7 +1,10 @@ export { CanvasControls } from './components/CanvasControls' -export { colors, fonts, spacing, deviceAccent, connectionColors } from "./theme"; -export { ConnectionLine } from "./components/ConnectionLine"; -export { DeviceCard } from "./components/DeviceCard"; -export { getIconPath } from "./icons"; -export { GroupOutline } from "./components/GroupOutline"; -export { TopologyCanvas } from "./components/TopologyCanvas"; +export { colors, fonts, spacing, deviceAccent, connectionColors } from './theme' +export { ConnectionLine } from './components/ConnectionLine' +export { DetailModal } from './components/DetailModal' +export { DeviceCard } from './components/DeviceCard' +export { getDeviceIconPath, getSpecIconPath, deviceIconPaths, specIconPaths } from './icons' +export { GroupOutline } from './components/GroupOutline' +export { PortStrip } from './components/PortStrip' +export { ServiceIcon, getServiceIconUrl } from './components/ServiceIcon' +export { TopologyCanvas } from './components/TopologyCanvas' diff --git a/packages/renderer/src/theme.ts b/packages/renderer/src/theme.ts index 1e1b0ef..eadb365 100644 --- a/packages/renderer/src/theme.ts +++ b/packages/renderer/src/theme.ts @@ -4,37 +4,37 @@ */ export const colors = { // Canvas - background: "#080f1e", - backgroundSubtle: "#0c1527", + background: '#080f1e', + backgroundSubtle: '#0c1527', // Primary accent — cyan/teal - primary: "#00e5ff", - primaryDim: "rgba(0, 229, 255, 0.15)", - primaryBorder: "rgba(0, 229, 255, 0.35)", + primary: '#00e5ff', + primaryDim: 'rgba(0, 229, 255, 0.15)', + primaryBorder: 'rgba(0, 229, 255, 0.35)', // Secondary accents - amber: "#ffab00", - amberDim: "rgba(255, 171, 0, 0.15)", - amberBorder: "rgba(255, 171, 0, 0.35)", + amber: '#ffab00', + amberDim: 'rgba(255, 171, 0, 0.15)', + amberBorder: 'rgba(255, 171, 0, 0.35)', - green: "#00e676", - greenDim: "rgba(0, 230, 118, 0.15)", + green: '#00e676', + greenDim: 'rgba(0, 230, 118, 0.15)', - red: "#ff1744", - redDim: "rgba(255, 23, 68, 0.15)", + red: '#ff1744', + redDim: 'rgba(255, 23, 68, 0.15)', - purple: "#d500f9", - purpleDim: "rgba(213, 0, 249, 0.15)", + purple: '#d500f9', + purpleDim: 'rgba(213, 0, 249, 0.15)', // Text - textPrimary: "#e0f7fa", - textSecondary: "#78909c", - textMuted: "#455a64", + textPrimary: '#e0f7fa', + textSecondary: '#78909c', + textMuted: '#455a64', // Borders - border: "rgba(0, 229, 255, 0.12)", - borderHover: "rgba(0, 229, 255, 0.4)", -} as const; + border: 'rgba(0, 229, 255, 0.12)', + borderHover: 'rgba(0, 229, 255, 0.4)', +} as const export const connectionColors: Record = { ethernet: colors.green, @@ -44,12 +44,12 @@ export const connectionColors: Record = { thunderbolt: colors.purple, fiber: colors.green, default: colors.textSecondary, -}; +} export const fonts = { mono: "'JetBrains Mono', 'Fira Code', 'SF Mono', 'Cascadia Code', monospace", sans: "'IBM Plex Sans', 'Inter', system-ui, sans-serif", -} as const; +} as const export const spacing = { xs: 4, @@ -57,38 +57,42 @@ export const spacing = { md: 12, lg: 20, xl: 32, -} as const; +} as const /** Returns the accent color for a given device type. */ export function deviceAccent(type: string): string { switch (type) { - case "router": - case "firewall": - case "modem": - return colors.primary; - case "switch": - case "ap": - return colors.primary; - case "server": - case "hypervisor": - case "nas": - return colors.amber; - case "vm": - case "container": - return colors.green; - case "camera": - case "iot": - return colors.red; - case "desktop": - case "laptop": - return colors.primary; - case "phone": - case "tablet": - case "tv": - return colors.purple; - case "vpn": - return colors.amber; + case 'router': + case 'firewall': + case 'modem': + case 'switch': + case 'ap': + return colors.primary + case 'server': + case 'hypervisor': + case 'nas': + case 'mini-pc': + case 'sbc': + return colors.amber + case 'vm': + case 'container': + return colors.green + case 'camera': + case 'iot': + case 'printer': + return colors.red + case 'desktop': + case 'laptop': + return colors.primary + case 'phone': + case 'tablet': + case 'tv': + case 'game-console': + case 'media-player': + return colors.purple + case 'vpn': + return colors.amber default: - return colors.textSecondary; + return colors.textSecondary } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2229ae..b5445b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,42 @@ importers: .: devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.1.0) + '@typescript-eslint/eslint-plugin': + specifier: ^8.57.2 + version: 8.57.2(@typescript-eslint/parser@8.57.2)(eslint@10.1.0)(typescript@5.5.2) + '@typescript-eslint/parser': + specifier: ^8.57.2 + version: 8.57.2(eslint@10.1.0)(typescript@5.5.2) + eslint: + specifier: ^10.1.0 + version: 10.1.0 + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@10.1.0) + eslint-plugin-react: + specifier: ^7.37.5 + version: 7.37.5(eslint@10.1.0) + eslint-plugin-react-hooks: + specifier: ^7.0.1 + version: 7.0.1(eslint@10.1.0) + husky: + specifier: ^9.1.7 + version: 9.1.7 + lint-staged: + specifier: ^16.4.0 + version: 16.4.0 + prettier: + specifier: ^3.8.1 + version: 3.8.1 typescript: specifier: ^5.5.0 version: 5.5.2 + typescript-eslint: + specifier: ^8.57.2 + version: 8.57.2(eslint@10.1.0)(typescript@5.5.2) apps/web: dependencies: @@ -599,6 +632,94 @@ packages: dev: true optional: true + /@eslint-community/eslint-utils@4.9.1(eslint@10.1.0): + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + dependencies: + eslint: 10.1.0 + eslint-visitor-keys: 3.4.3 + dev: true + + /@eslint-community/regexpp@4.12.2: + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + dev: true + + /@eslint/config-array@0.23.3: + resolution: {integrity: sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + dependencies: + '@eslint/object-schema': 3.0.3 + debug: 4.4.3 + minimatch: 10.2.4 + transitivePeerDependencies: + - supports-color + dev: true + + /@eslint/config-helpers@0.5.3: + resolution: {integrity: sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + dependencies: + '@eslint/core': 1.1.1 + dev: true + + /@eslint/core@1.1.1: + resolution: {integrity: sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + dependencies: + '@types/json-schema': 7.0.15 + dev: true + + /@eslint/js@10.0.1(eslint@10.1.0): + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + dependencies: + eslint: 10.1.0 + dev: true + + /@eslint/object-schema@3.0.3: + resolution: {integrity: sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + dev: true + + /@eslint/plugin-kit@0.6.1: + resolution: {integrity: sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + dependencies: + '@eslint/core': 1.1.1 + levn: 0.4.1 + dev: true + + /@humanfs/core@0.19.1: + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + dev: true + + /@humanfs/node@0.16.7: + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + dev: true + + /@humanwhocodes/module-importer@1.0.1: + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + dev: true + + /@humanwhocodes/retry@0.4.3: + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + dev: true + /@jridgewell/gen-mapping@0.3.13: resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} dependencies: @@ -1063,6 +1184,10 @@ packages: resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} dev: true + /@types/esrecurse@4.3.1: + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + dev: true + /@types/estree@1.0.8: resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} dev: true @@ -1071,6 +1196,10 @@ packages: resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} dev: true + /@types/json-schema@7.0.15: + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + dev: true + /@types/node@25.5.0: resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} dependencies: @@ -1094,6 +1223,146 @@ packages: csstype: 3.2.3 dev: true + /@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2)(eslint@10.1.0)(typescript@5.5.2): + resolution: {integrity: sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.57.2 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.57.2(eslint@10.1.0)(typescript@5.5.2) + '@typescript-eslint/scope-manager': 8.57.2 + '@typescript-eslint/type-utils': 8.57.2(eslint@10.1.0)(typescript@5.5.2) + '@typescript-eslint/utils': 8.57.2(eslint@10.1.0)(typescript@5.5.2) + '@typescript-eslint/visitor-keys': 8.57.2 + eslint: 10.1.0 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.5.2) + typescript: 5.5.2 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/parser@8.57.2(eslint@10.1.0)(typescript@5.5.2): + resolution: {integrity: sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + dependencies: + '@typescript-eslint/scope-manager': 8.57.2 + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.5.2) + '@typescript-eslint/visitor-keys': 8.57.2 + debug: 4.4.3 + eslint: 10.1.0 + typescript: 5.5.2 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/project-service@8.57.2(typescript@5.5.2): + resolution: {integrity: sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + dependencies: + '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.5.2) + '@typescript-eslint/types': 8.57.2 + debug: 4.4.3 + typescript: 5.5.2 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/scope-manager@8.57.2: + resolution: {integrity: sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + dependencies: + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/visitor-keys': 8.57.2 + dev: true + + /@typescript-eslint/tsconfig-utils@8.57.2(typescript@5.5.2): + resolution: {integrity: sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + dependencies: + typescript: 5.5.2 + dev: true + + /@typescript-eslint/type-utils@8.57.2(eslint@10.1.0)(typescript@5.5.2): + resolution: {integrity: sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + dependencies: + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.5.2) + '@typescript-eslint/utils': 8.57.2(eslint@10.1.0)(typescript@5.5.2) + debug: 4.4.3 + eslint: 10.1.0 + ts-api-utils: 2.5.0(typescript@5.5.2) + typescript: 5.5.2 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/types@8.57.2: + resolution: {integrity: sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + dev: true + + /@typescript-eslint/typescript-estree@8.57.2(typescript@5.5.2): + resolution: {integrity: sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + dependencies: + '@typescript-eslint/project-service': 8.57.2(typescript@5.5.2) + '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.5.2) + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/visitor-keys': 8.57.2 + debug: 4.4.3 + minimatch: 10.2.4 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.5.0(typescript@5.5.2) + typescript: 5.5.2 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/utils@8.57.2(eslint@10.1.0)(typescript@5.5.2): + resolution: {integrity: sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0) + '@typescript-eslint/scope-manager': 8.57.2 + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.5.2) + eslint: 10.1.0 + typescript: 5.5.2 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/visitor-keys@8.57.2: + resolution: {integrity: sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + dependencies: + '@typescript-eslint/types': 8.57.2 + eslint-visitor-keys: 5.0.1 + dev: true + /@vitejs/plugin-react@4.3.0(vite@5.4.0): resolution: {integrity: sha512-KcEbMsn4Dpk+LIbHMj7gDPRKaTMStxxWRkRmxsg/jVdFdJCZWt1SchZcf0M4t8lIKdwwMsEyzhrcOXRrDPtOBw==} engines: {node: ^14.18.0 || >=16.0.0} @@ -1172,15 +1441,154 @@ packages: tinyrainbow: 3.1.0 dev: true + /acorn-jsx@5.3.2(acorn@8.16.0): + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + dependencies: + acorn: 8.16.0 + dev: true + + /acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + dev: true + + /ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + dev: true + + /ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + dependencies: + environment: 1.1.0 + dev: true + + /ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + dev: true + + /ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + dev: true + /argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} dev: false + /array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + dev: true + + /array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + dev: true + + /array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + dev: true + + /array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-shim-unscopables: 1.1.0 + dev: true + + /array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-shim-unscopables: 1.1.0 + dev: true + + /array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 + dev: true + + /arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + dev: true + /assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} dev: true + /async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + dev: true + + /available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + dependencies: + possible-typed-array-names: 1.1.0 + dev: true + + /balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + dev: true + + /balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + dev: true + /base64-arraybuffer@1.0.2: resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} engines: {node: '>= 0.6.0'} @@ -1192,6 +1600,20 @@ packages: hasBin: true dev: true + /brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + dev: true + + /brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + dependencies: + balanced-match: 4.0.4 + dev: true + /browserslist@4.28.1: resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -1204,6 +1626,32 @@ packages: update-browserslist-db: 1.2.3(browserslist@4.28.1) dev: true + /call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + dev: true + + /call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + dev: true + + /call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + dev: true + /caniuse-lite@1.0.30001780: resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==} dev: true @@ -1213,6 +1661,21 @@ packages: engines: {node: '>=18'} dev: true + /cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + dependencies: + restore-cursor: 5.1.0 + dev: true + + /cli-truncate@5.2.0: + resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} + engines: {node: '>=20'} + dependencies: + slice-ansi: 8.0.0 + string-width: 8.2.0 + dev: true + /codemirror@6.0.2: resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} dependencies: @@ -1225,6 +1688,19 @@ packages: '@codemirror/view': 6.40.0 dev: false + /colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + dev: true + + /commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + dev: true + + /concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + dev: true + /convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} dev: true @@ -1233,6 +1709,15 @@ packages: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} dev: false + /cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + dev: true + /css-line-break@2.1.0: resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} dependencies: @@ -1243,6 +1728,33 @@ packages: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} dev: true + /data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + dev: true + + /data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + dev: true + + /data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + dev: true + /debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1255,19 +1767,192 @@ packages: ms: 2.1.3 dev: true + /deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + dev: true + + /define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + dev: true + + /define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + dev: true + /detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} dev: true + /doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + dependencies: + esutils: 2.0.3 + dev: true + + /dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + dev: true + /electron-to-chromium@1.5.321: resolution: {integrity: sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==} dev: true + /emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + dev: true + + /environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + dev: true + + /es-abstract@1.24.1: + resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} + engines: {node: '>= 0.4'} + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.3 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.7 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.20 + dev: true + + /es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + dev: true + + /es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + dev: true + + /es-iterator-helpers@1.3.1: + resolution: {integrity: sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + math-intrinsics: 1.1.0 + safe-array-concat: 1.1.3 + dev: true + /es-module-lexer@2.0.0: resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} dev: true + /es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + dev: true + + /es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + dev: true + + /es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + dependencies: + hasown: 2.0.2 + dev: true + + /es-to-primitive@1.3.0: + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + engines: {node: '>= 0.4'} + dependencies: + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + dev: true + /esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -1304,43 +1989,377 @@ packages: engines: {node: '>=6'} dev: true - /estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + /escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + dev: true + + /eslint-config-prettier@10.1.8(eslint@10.1.0): + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' dependencies: - '@types/estree': 1.0.8 + eslint: 10.1.0 dev: true - /expect-type@1.3.0: - resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} - engines: {node: '>=12.0.0'} + /eslint-plugin-react-hooks@7.0.1(eslint@10.1.0): + resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.2 + eslint: 10.1.0 + hermes-parser: 0.25.1 + zod: 4.3.6 + zod-validation-error: 4.0.2(zod@4.3.6) + transitivePeerDependencies: + - supports-color dev: true - /fdir@6.5.0(picomatch@4.0.3): - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} + /eslint-plugin-react@7.37.5(eslint@10.1.0): + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + engines: {node: '>=4'} peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.3.1 + eslint: 10.1.0 + estraverse: 5.3.0 + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.5 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.6 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + dev: true + + /eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} dependencies: - picomatch: 4.0.3 + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.8 + esrecurse: 4.3.0 + estraverse: 5.3.0 dev: true - /fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - requiresBuild: true + /eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true - optional: true - /gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} + /eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} dev: true - /html2canvas@1.4.1: + /eslint@10.1.0: + resolution: {integrity: sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.3 + '@eslint/config-helpers': 0.5.3 + '@eslint/core': 1.1.1 + '@eslint/plugin-kit': 0.6.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.4 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + dev: true + + /espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 5.0.1 + dev: true + + /esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + dependencies: + estraverse: 5.3.0 + dev: true + + /esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + dependencies: + estraverse: 5.3.0 + dev: true + + /estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + dev: true + + /estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + dependencies: + '@types/estree': 1.0.8 + dev: true + + /esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + dev: true + + /eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + dev: true + + /expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + dev: true + + /fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + dev: true + + /fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + dev: true + + /fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + dev: true + + /fdir@6.5.0(picomatch@4.0.3): + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + dependencies: + picomatch: 4.0.3 + dev: true + + /file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + dependencies: + flat-cache: 4.0.1 + dev: true + + /find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + dev: true + + /flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + dev: true + + /flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + dev: true + + /for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + dependencies: + is-callable: 1.2.7 + dev: true + + /fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + dev: true + + /function.prototype.name@1.1.8: + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + functions-have-names: 1.2.3 + hasown: 2.0.2 + is-callable: 1.2.7 + dev: true + + /functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + dev: true + + /generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + dev: true + + /gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + dev: true + + /get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + engines: {node: '>=18'} + dev: true + + /get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + dev: true + + /get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + dev: true + + /get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + dev: true + + /glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + dependencies: + is-glob: 4.0.3 + dev: true + + /globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + dev: true + + /gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + dev: true + + /has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + dev: true + + /has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + dependencies: + es-define-property: 1.0.1 + dev: true + + /has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + dependencies: + dunder-proto: 1.0.1 + dev: true + + /has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + dev: true + + /has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + dependencies: + has-symbols: 1.1.0 + dev: true + + /hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + dependencies: + function-bind: 1.1.2 + dev: true + + /hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + dev: true + + /hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + dependencies: + hermes-estree: 0.25.1 + dev: true + + /html2canvas@1.4.1: resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} engines: {node: '>=8.0.0'} dependencies: @@ -1348,6 +2367,241 @@ packages: text-segmentation: 1.0.3 dev: false + /husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + dev: true + + /ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + dev: true + + /ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + dev: true + + /imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + dev: true + + /internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.1.0 + dev: true + + /is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + dev: true + + /is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + dev: true + + /is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + dependencies: + has-bigints: 1.1.0 + dev: true + + /is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + dev: true + + /is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + dev: true + + /is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + dependencies: + hasown: 2.0.2 + dev: true + + /is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + dev: true + + /is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + dev: true + + /is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + dev: true + + /is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + dev: true + + /is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + dependencies: + get-east-asian-width: 1.5.0 + dev: true + + /is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + dev: true + + /is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + dependencies: + is-extglob: 2.1.1 + dev: true + + /is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + dev: true + + /is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + dev: true + + /is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + dev: true + + /is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + dev: true + + /is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + dev: true + + /is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + dev: true + + /is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + dev: true + + /is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + dev: true + + /is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + dependencies: + which-typed-array: 1.1.20 + dev: true + + /is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + dev: true + + /is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + dev: true + + /is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + dev: true + + /isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + dev: true + + /isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + dev: true + + /iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + dev: true + /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1364,12 +2618,48 @@ packages: hasBin: true dev: true + /json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + dev: true + + /json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + dev: true + + /json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + dev: true + /json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} hasBin: true dev: true + /jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + dependencies: + array-includes: 3.1.9 + array.prototype.flat: 1.3.3 + object.assign: 4.1.7 + object.values: 1.2.1 + dev: true + + /keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + dependencies: + json-buffer: 3.0.1 + dev: true + + /levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + dev: true + /lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -1488,12 +2778,54 @@ packages: lightningcss-win32-x64-msvc: 1.32.0 dev: true + /lint-staged@16.4.0: + resolution: {integrity: sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==} + engines: {node: '>=20.17'} + hasBin: true + dependencies: + commander: 14.0.3 + listr2: 9.0.5 + picomatch: 4.0.3 + string-argv: 0.3.2 + tinyexec: 1.0.4 + yaml: 2.8.3 + dev: true + + /listr2@9.0.5: + resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} + engines: {node: '>=20.0.0'} + dependencies: + cli-truncate: 5.2.0 + colorette: 2.0.20 + eventemitter3: 5.0.4 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.2 + dev: true + + /locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + dependencies: + p-locate: 5.0.0 + dev: true + + /log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + dependencies: + ansi-escapes: 7.3.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.2 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + dev: true + /loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true dependencies: js-tokens: 4.0.0 - dev: false /lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -1507,6 +2839,29 @@ packages: '@jridgewell/sourcemap-codec': 1.5.5 dev: true + /math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + dev: true + + /mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + dev: true + + /minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + engines: {node: 18 || 20 || >=22} + dependencies: + brace-expansion: 5.0.5 + dev: true + + /minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + dependencies: + brace-expansion: 1.1.12 + dev: true + /ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} dev: true @@ -1517,14 +2872,141 @@ packages: hasBin: true dev: true + /natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + dev: true + + /node-exports-info@1.6.0: + resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} + engines: {node: '>= 0.4'} + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 + dev: true + /node-releases@2.0.36: resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} dev: true + /object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + dev: true + + /object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + dev: true + + /object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + dev: true + + /object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + dev: true + + /object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + dev: true + + /object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + dev: true + + /object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + dev: true + /obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} dev: true + /onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + dependencies: + mimic-function: 5.0.1 + dev: true + + /optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + dev: true + + /own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + dev: true + + /p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + dependencies: + yocto-queue: 0.1.0 + dev: true + + /p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + dependencies: + p-limit: 3.1.0 + dev: true + + /path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + dev: true + + /path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + dev: true + + /path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + dev: true + /pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} dev: true @@ -1538,6 +3020,11 @@ packages: engines: {node: '>=12'} dev: true + /possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + dev: true + /postcss@8.5.8: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} @@ -1547,6 +3034,30 @@ packages: source-map-js: 1.2.1 dev: true + /prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + dev: true + + /prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + engines: {node: '>=14'} + hasBin: true + dev: true + + /prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + dev: true + + /punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + dev: true + /react-dom@18.3.1(react@18.3.1): resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: @@ -1557,6 +3068,10 @@ packages: scheduler: 0.23.2 dev: false + /react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + dev: true + /react-refresh@0.14.2: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} @@ -1569,6 +3084,57 @@ packages: loose-envify: 1.4.0 dev: false + /reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + dev: true + + /regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + dev: true + + /resolve@2.0.0-next.6: + resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==} + engines: {node: '>= 0.4'} + hasBin: true + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.1 + node-exports-info: 1.6.0 + object-keys: 1.1.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + dev: true + + /restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + dev: true + + /rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + dev: true + /rolldown@1.0.0-rc.10: resolution: {integrity: sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1629,6 +3195,34 @@ packages: fsevents: 2.3.3 dev: true + /safe-array-concat@1.1.3: + resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} + engines: {node: '>=0.4'} + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + dev: true + + /safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + dev: true + + /safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + dev: true + /scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} dependencies: @@ -1640,10 +3234,120 @@ packages: hasBin: true dev: true + /semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + dev: true + + /set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + dev: true + + /set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + dev: true + + /set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + dev: true + + /shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + dependencies: + shebang-regex: 3.0.0 + dev: true + + /shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + dev: true + + /side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + dev: true + + /side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + dev: true + + /side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + dev: true + + /side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + dev: true + /siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} dev: true + /signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + dev: true + + /slice-ansi@7.1.2: + resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} + engines: {node: '>=18'} + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + dev: true + + /slice-ansi@8.0.0: + resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} + engines: {node: '>=20'} + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + dev: true + /source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1657,10 +3361,110 @@ packages: resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} dev: true + /stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + dev: true + + /string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + dev: true + + /string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + dev: true + + /string-width@8.2.0: + resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} + engines: {node: '>=20'} + dependencies: + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + dev: true + + /string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.0 + dev: true + + /string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + dependencies: + define-properties: 1.2.1 + es-abstract: 1.24.1 + dev: true + + /string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + has-property-descriptors: 1.0.2 + dev: true + + /string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + dev: true + + /string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + dev: true + + /strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + dependencies: + ansi-regex: 6.2.2 + dev: true + /style-mod@4.1.3: resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} dev: false + /supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + dev: true + /text-segmentation@1.0.3: resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} dependencies: @@ -1689,18 +3493,106 @@ packages: engines: {node: '>=14.0.0'} dev: true + /ts-api-utils@2.5.0(typescript@5.5.2): + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + dependencies: + typescript: 5.5.2 + dev: true + /tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} requiresBuild: true dev: true optional: true + /type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + dependencies: + prelude-ls: 1.2.1 + dev: true + + /typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + dev: true + + /typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + dev: true + + /typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + dev: true + + /typed-array-length@1.0.7: + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + dev: true + + /typescript-eslint@8.57.2(eslint@10.1.0)(typescript@5.5.2): + resolution: {integrity: sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + dependencies: + '@typescript-eslint/eslint-plugin': 8.57.2(@typescript-eslint/parser@8.57.2)(eslint@10.1.0)(typescript@5.5.2) + '@typescript-eslint/parser': 8.57.2(eslint@10.1.0)(typescript@5.5.2) + '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.5.2) + '@typescript-eslint/utils': 8.57.2(eslint@10.1.0)(typescript@5.5.2) + eslint: 10.1.0 + typescript: 5.5.2 + transitivePeerDependencies: + - supports-color + dev: true + /typescript@5.5.2: resolution: {integrity: sha512-NcRtPEOsPFFWjobJEtfihkLCZCXZt/os3zf8nTxjVH3RvTSxjrCamJpbExGvYOF+tFHc3pA65qpdwPbzjohhew==} engines: {node: '>=14.17'} hasBin: true dev: true + /unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + dev: true + /undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} dev: true @@ -1716,6 +3608,12 @@ packages: picocolors: 1.1.1 dev: true + /uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + dependencies: + punycode: 2.3.1 + dev: true + /utrie@1.0.2: resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} dependencies: @@ -1876,6 +3774,67 @@ packages: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} dev: false + /which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + dev: true + + /which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.1.8 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.20 + dev: true + + /which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + dev: true + + /which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + engines: {node: '>= 0.4'} + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + dev: true + + /which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + dependencies: + isexe: 2.0.0 + dev: true + /why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -1885,6 +3844,44 @@ packages: stackback: 0.0.2 dev: true + /word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + dev: true + + /wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + dev: true + /yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} dev: true + + /yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} + hasBin: true + dev: true + + /yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + dev: true + + /zod-validation-error@4.0.2(zod@4.3.6): + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + dependencies: + zod: 4.3.6 + dev: true + + /zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + dev: true diff --git a/tsconfig.json b/tsconfig.json index 21acccf..8d46e53 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,7 @@ "declaration": true, "declarationMap": true, "sourceMap": true, - "jsx": "react-jsx", + "jsx": "react-jsx" }, "include": [] }