- {data.seqActivations.map((startOrder, i) => {
- if (i % 2 !== 0) return null;
- const endOrder = data.seqActivations![i + 1] ?? startOrder + 1;
+ {activationRanges.map(({ startOrder, endOrder }, i) => {
const y = SEQ_MSG_OFFSET + startOrder * SEQ_MSG_SPACING;
const h = (endOrder - startOrder) * SEQ_MSG_SPACING;
return (
diff --git a/src/components/flow-canvas/flowCanvasTypes.test.ts b/src/components/flow-canvas/flowCanvasTypes.test.ts
index f4591361..c7aa6a74 100644
--- a/src/components/flow-canvas/flowCanvasTypes.test.ts
+++ b/src/components/flow-canvas/flowCanvasTypes.test.ts
@@ -19,6 +19,7 @@ describe('flowCanvasNodeTypes', () => {
"mindmap",
"mobile",
"process",
+ "section",
"sequence_note",
"sequence_participant",
"start",
diff --git a/src/components/flow-canvas/flowCanvasTypes.tsx b/src/components/flow-canvas/flowCanvasTypes.tsx
index 3e0fbad8..6ebf0bbf 100644
--- a/src/components/flow-canvas/flowCanvasTypes.tsx
+++ b/src/components/flow-canvas/flowCanvasTypes.tsx
@@ -20,6 +20,7 @@ import JourneyNode from '@/components/custom-nodes/JourneyNode';
import ArchitectureNode from '@/components/custom-nodes/ArchitectureNode';
import SequenceParticipantNode from '@/components/custom-nodes/SequenceParticipantNode';
import SequenceNoteNode from '@/components/custom-nodes/SequenceNoteNode';
+import SectionNode from '@/components/SectionNode';
export const flowCanvasNodeTypes: NodeTypes = {
start: CustomNode,
@@ -34,6 +35,7 @@ export const flowCanvasNodeTypes: NodeTypes = {
architecture: ArchitectureNode,
annotation: AnnotationNode,
text: TextNode,
+ section: SectionNode,
swimlane: SwimlaneNode,
image: ImageNode,
browser: BrowserNode,
diff --git a/src/components/flow-canvas/useFlowCanvasPaste.ts b/src/components/flow-canvas/useFlowCanvasPaste.ts
index 8479c71b..c4b6d0da 100644
--- a/src/components/flow-canvas/useFlowCanvasPaste.ts
+++ b/src/components/flow-canvas/useFlowCanvasPaste.ts
@@ -1,162 +1,319 @@
import { useCallback } from 'react';
import { useFlowStore } from '@/store';
+
import type { FlowEdge, FlowNode } from '@/lib/types';
import type { MermaidDiagnosticsSnapshot } from '@/store/types';
-import { createPastedTextNode, isEditablePasteTarget, resolveLayoutDirection } from './pasteHelpers';
+import {
+ createPastedTextNode,
+ isEditablePasteTarget,
+ resolveLayoutDirection,
+} from './pasteHelpers';
import { detectMermaidDiagramType } from '@/services/mermaid/detectDiagramType';
+import { extractMermaidDiagramHeader } from '@/services/mermaid/detectDiagramType';
import { normalizeParseDiagnostics } from '@/services/mermaid/diagnosticFormatting';
+import { buildMermaidDiagnosticsSnapshot } from '@/services/mermaid/diagnosticsSnapshot';
+import {
+ appendMermaidImportGuidance,
+ getMermaidImportToastMessage,
+} from '@/services/mermaid/importStatePresentation';
+import {
+ getOfficialMermaidDiagnostics,
+ getOfficialMermaidErrorMessage,
+ isOfficialMermaidValidationBlocking,
+ validateMermaidWithOfficialParser,
+} from '@/services/mermaid/officialMermaidValidation';
import { parseMermaidByType } from '@/services/mermaid/parseMermaidByType';
+import { composeDiagramForDisplay } from '@/services/composeDiagramForDisplay';
+import { enrichNodesWithIcons } from '@/lib/nodeEnricher';
+import { normalizeNodeIconData } from '@/lib/nodeIconState';
import { assignSmartHandles } from '@/services/smartEdgeRouting';
+import type { LayoutOptions } from '@/services/elk-layout/types';
+import { attachImportLayoutMetadata } from '@/services/importLayoutMetadata';
+
+const IMPORT_LABEL_COMPACT_THRESHOLD = 10;
+const IMPORT_LABEL_VERBOSE_THRESHOLD = 20;
+const IMPORT_LARGE_DIAGRAM_THRESHOLD = 36;
+
+function getAverageLabelLength(nodes: FlowNode[]): number {
+ if (nodes.length === 0) return 0;
+ const total = nodes.reduce((sum, node) => sum + String(node.data?.label ?? '').trim().length, 0);
+ return total / nodes.length;
+}
+
+function resolveImportLayoutOptions(
+ nodes: FlowNode[],
+ diagramType?: string
+): { spacing: NonNullable
; contentDensity: NonNullable } {
+ const avg = getAverageLabelLength(nodes);
+
+ let spacing: NonNullable;
+ if (diagramType === 'architecture') {
+ spacing = nodes.length >= 24 ? 'normal' : 'compact';
+ } else if (avg <= IMPORT_LABEL_COMPACT_THRESHOLD) {
+ spacing = 'compact';
+ } else if (avg <= IMPORT_LABEL_VERBOSE_THRESHOLD) {
+ spacing = nodes.length >= IMPORT_LARGE_DIAGRAM_THRESHOLD ? 'loose' : 'normal';
+ } else {
+ spacing = 'loose';
+ }
+
+ const contentDensity: NonNullable =
+ avg <= IMPORT_LABEL_COMPACT_THRESHOLD ? 'compact'
+ : avg <= IMPORT_LABEL_VERBOSE_THRESHOLD ? 'balanced'
+ : 'verbose';
+
+ return { spacing, contentDensity };
+}
type SetFlowNodes = (payload: FlowNode[] | ((nodes: FlowNode[]) => FlowNode[])) => void;
type SetFlowEdges = (payload: FlowEdge[] | ((edges: FlowEdge[]) => FlowEdge[])) => void;
-type AddToast = (message: string, type?: 'success' | 'error' | 'info' | 'warning', duration?: number) => void;
+type AddToast = (
+ message: string,
+ type?: 'success' | 'error' | 'info' | 'warning',
+ duration?: number
+) => void;
interface UseFlowCanvasPasteParams {
- architectureStrictMode: boolean;
- activeTabId: string;
- fitView: (options?: { duration?: number; padding?: number }) => void;
- updateTab: (tabId: string, updates: Partial<{ diagramType: string }>) => void;
- recordHistory: () => void;
- setNodes: SetFlowNodes;
- setEdges: SetFlowEdges;
- setSelectedNodeId: (id: string | null) => void;
- setMermaidDiagnostics: (payload: MermaidDiagnosticsSnapshot | null) => void;
- clearMermaidDiagnostics: () => void;
- addToast: AddToast;
- strictModePasteBlockedMessage: string;
- pasteSelection: (center?: { x: number; y: number }) => void;
- getLastInteractionFlowPosition: () => { x: number; y: number } | null;
- getCanvasCenterFlowPosition: () => { x: number; y: number };
+ architectureStrictMode: boolean;
+ activeTabId: string;
+ fitView: (options?: { duration?: number; padding?: number }) => void;
+ updateTab: (tabId: string, updates: Partial<{ diagramType: string }>) => void;
+ recordHistory: () => void;
+ setNodes: SetFlowNodes;
+ setEdges: SetFlowEdges;
+ setSelectedNodeId: (id: string | null) => void;
+ setMermaidDiagnostics: (payload: MermaidDiagnosticsSnapshot | null) => void;
+ clearMermaidDiagnostics: () => void;
+ addToast: AddToast;
+ strictModePasteBlockedMessage: string;
+ pasteSelection: (center?: { x: number; y: number }) => void;
+ getLastInteractionFlowPosition: () => { x: number; y: number } | null;
+ getCanvasCenterFlowPosition: () => { x: number; y: number };
}
export function useFlowCanvasPaste({
- architectureStrictMode,
- activeTabId,
- fitView,
- updateTab,
- recordHistory,
- setNodes,
- setEdges,
- setSelectedNodeId,
- setMermaidDiagnostics,
- clearMermaidDiagnostics,
- addToast,
- strictModePasteBlockedMessage,
- pasteSelection,
- getLastInteractionFlowPosition,
- getCanvasCenterFlowPosition,
+ architectureStrictMode,
+ activeTabId,
+ fitView,
+ updateTab,
+ recordHistory,
+ setNodes,
+ setEdges,
+ setSelectedNodeId,
+ setMermaidDiagnostics,
+ clearMermaidDiagnostics,
+ addToast,
+ strictModePasteBlockedMessage,
+ pasteSelection,
+ getLastInteractionFlowPosition,
+ getCanvasCenterFlowPosition,
}: UseFlowCanvasPasteParams) {
- const handleCanvasPaste = useCallback(async (event: React.ClipboardEvent): Promise => {
- if (isEditablePasteTarget(event.target)) return;
- const rawText = event.clipboardData.getData('text/plain');
- const pastedText = rawText.trim();
+ const safelyEnrichImportedNodes = useCallback(
+ (nodes: FlowNode[], diagramType: MermaidDiagnosticsSnapshot['diagramType']): FlowNode[] => {
+ try {
+ return enrichNodesWithIcons(nodes, {
+ diagramType,
+ mode: 'mermaid-import',
+ }).map((node) => ({
+ ...node,
+ data: normalizeNodeIconData(node.data),
+ }));
+ } catch {
+ addToast(
+ 'Imported diagram rendered without icon enrichment due to an enrichment error.',
+ 'warning'
+ );
+ return nodes;
+ }
+ },
+ [addToast]
+ );
- if (!pastedText) {
- pasteSelection(getLastInteractionFlowPosition() ?? getCanvasCenterFlowPosition());
- return;
- }
+ const handleCanvasPaste = useCallback(
+ async (event: React.ClipboardEvent): Promise => {
+ if (isEditablePasteTarget(event.target)) return;
- event.preventDefault();
-
- const maybeMermaidType = detectMermaidDiagramType(pastedText);
- if (maybeMermaidType) {
- const result = parseMermaidByType(pastedText, { architectureStrictMode });
- const diagnostics = normalizeParseDiagnostics(result.diagnostics);
-
- if (!result.error) {
- if (diagnostics.length > 0) {
- setMermaidDiagnostics({
- source: 'paste',
- diagramType: result.diagramType,
- diagnostics,
- updatedAt: Date.now(),
- });
- addToast(`Imported with ${diagnostics.length} diagnostic warning(s).`, 'warning');
- } else {
- clearMermaidDiagnostics();
- }
+ const rawText = event.clipboardData.getData('text/plain');
+ const pastedText = rawText.trim();
- recordHistory();
-
- if (result.nodes.length > 0) {
- try {
- const { getElkLayout } = await import('@/services/elkLayout');
- const layoutDirection = resolveLayoutDirection(result);
- const { nodes: layoutedNodes, edges: layoutedEdges } = await getElkLayout(result.nodes, result.edges, {
- direction: layoutDirection,
- algorithm: 'layered',
- spacing: 'normal',
- });
- const smartEdges = assignSmartHandles(layoutedNodes, layoutedEdges);
- setNodes(layoutedNodes);
- setEdges(smartEdges);
- } catch {
- setNodes(result.nodes);
- setEdges(result.edges);
- }
- } else {
- setNodes(result.nodes);
- setEdges(result.edges);
- }
+ if (!pastedText) {
+ pasteSelection(getLastInteractionFlowPosition() ?? getCanvasCenterFlowPosition());
+ return;
+ }
- if ('diagramType' in result && result.diagramType) {
- updateTab(activeTabId, { diagramType: result.diagramType });
- }
+ event.preventDefault();
- window.setTimeout(() => fitView({ duration: 600, padding: 0.2 }), 80);
- return;
- }
+ const mermaidHeader = extractMermaidDiagramHeader(pastedText);
+ const maybeMermaidType = mermaidHeader.diagramType ?? detectMermaidDiagramType(pastedText);
+ if (mermaidHeader.rawType) {
+ const officialMermaidValidation = await validateMermaidWithOfficialParser(pastedText);
+ const officialDiagnostics = getOfficialMermaidDiagnostics(officialMermaidValidation);
+
+ if (isOfficialMermaidValidationBlocking(officialMermaidValidation)) {
+ const rawErrorMessage =
+ getOfficialMermaidErrorMessage(officialMermaidValidation)
+ ?? 'Official Mermaid validation failed.';
+ const errorMessage = appendMermaidImportGuidance({
+ message: rawErrorMessage,
+ importState: officialMermaidValidation.detectedType ? 'unsupported_construct' : 'invalid_source',
+ diagramType: officialMermaidValidation.detectedType ?? maybeMermaidType ?? undefined,
+ });
- setMermaidDiagnostics({
+ setMermaidDiagnostics(
+ buildMermaidDiagnosticsSnapshot({
+ source: 'paste',
+ diagramType: officialMermaidValidation.detectedType ?? maybeMermaidType,
+ importState: officialMermaidValidation.detectedType ? 'unsupported_construct' : 'invalid_source',
+ originalSource: pastedText,
+ diagnostics: officialDiagnostics,
+ error: errorMessage,
+ })
+ );
+
+ addToast(errorMessage, 'error');
+ return;
+ }
+
+ const result = parseMermaidByType(pastedText, { architectureStrictMode });
+ const parserDiagnostics = normalizeParseDiagnostics(result.diagnostics);
+ const diagnostics = [...officialDiagnostics, ...parserDiagnostics];
+
+ if (!result.error) {
+ if (diagnostics.length > 0) {
+ setMermaidDiagnostics(
+ buildMermaidDiagnosticsSnapshot({
source: 'paste',
- diagramType: result.diagramType ?? maybeMermaidType,
+ diagramType: result.diagramType,
+ importState: result.importState,
+ originalSource: result.originalSource,
diagnostics,
- error: result.error,
- updatedAt: Date.now(),
+ nodeCount: result.nodes.length,
+ edgeCount: result.edges.length,
+ })
+ );
+ const toastMessage = getMermaidImportToastMessage({
+ importState: result.importState,
+ warningCount: diagnostics.length,
});
+ if (toastMessage) {
+ addToast(toastMessage, 'warning');
+ }
+ } else {
+ clearMermaidDiagnostics();
+ }
+
+ recordHistory();
- if (maybeMermaidType === 'architecture' && architectureStrictMode && result.error.includes('strict mode rejected')) {
- addToast(strictModePasteBlockedMessage, 'error');
- return;
+ if (result.nodes.length > 0) {
+ const enrichedNodes = safelyEnrichImportedNodes(result.nodes, result.diagramType);
+ try {
+ const { clearLayoutCache } = await import('@/services/elkLayout');
+ clearLayoutCache();
+ const layoutDirection = resolveLayoutDirection(result);
+ const { spacing, contentDensity } = resolveImportLayoutOptions(enrichedNodes, result.diagramType);
+ const { nodes: layoutedNodes, edges: layoutedEdges } = await composeDiagramForDisplay(
+ enrichedNodes,
+ result.edges,
+ {
+ direction: layoutDirection,
+ spacing,
+ contentDensity,
+ diagramType: result.diagramType,
+ source: 'import',
+ }
+ );
+ const smartEdges = assignSmartHandles(layoutedNodes, layoutedEdges);
+ const importSignature = `mermaid-import-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+ setNodes(
+ attachImportLayoutMetadata(layoutedNodes, {
+ signature: importSignature,
+ direction: layoutDirection,
+ spacing,
+ contentDensity,
+ diagramType: result.diagramType,
+ })
+ );
+ setEdges(smartEdges);
+ } catch {
+ setNodes(enrichedNodes);
+ setEdges(result.edges);
}
+ } else {
+ setNodes(result.nodes);
+ setEdges(result.edges);
+ }
+
+ if ('diagramType' in result && result.diagramType) {
+ updateTab(activeTabId, { diagramType: result.diagramType });
+ }
- addToast(result.error, 'error');
- return;
+ window.setTimeout(() => fitView({ duration: 600, padding: 0.2 }), 80);
+ return;
}
- const pasteFlowPosition =
- getLastInteractionFlowPosition() ?? getCanvasCenterFlowPosition();
-
- recordHistory();
- const { activeLayerId } = useFlowStore.getState();
- const newTextNode = createPastedTextNode(pastedText, pasteFlowPosition, activeLayerId);
-
- setNodes((existingNodes) => [
- ...existingNodes.map((node) => ({ ...node, selected: false })),
- { ...newTextNode, selected: true },
- ]);
- setSelectedNodeId(newTextNode.id);
- }, [
- activeTabId,
- addToast,
- architectureStrictMode,
- clearMermaidDiagnostics,
- fitView,
- getCanvasCenterFlowPosition,
- pasteSelection,
- getLastInteractionFlowPosition,
- recordHistory,
- setEdges,
- setMermaidDiagnostics,
- setNodes,
- setSelectedNodeId,
- strictModePasteBlockedMessage,
- updateTab,
- ]);
-
- return {
- handleCanvasPaste,
- };
+ const errorMessage = appendMermaidImportGuidance({
+ message: result.error,
+ importState: result.importState,
+ diagramType: result.diagramType ?? maybeMermaidType ?? undefined,
+ });
+ setMermaidDiagnostics(
+ buildMermaidDiagnosticsSnapshot({
+ source: 'paste',
+ diagramType: result.diagramType ?? maybeMermaidType,
+ importState: result.importState,
+ originalSource: result.originalSource,
+ diagnostics,
+ error: errorMessage,
+ })
+ );
+
+ if (
+ maybeMermaidType === 'architecture' &&
+ architectureStrictMode &&
+ result.error.includes('strict mode rejected')
+ ) {
+ addToast(strictModePasteBlockedMessage, 'error');
+ return;
+ }
+
+ addToast(errorMessage, 'error');
+ return;
+ }
+
+ const pasteFlowPosition = getLastInteractionFlowPosition() ?? getCanvasCenterFlowPosition();
+
+ recordHistory();
+ const { activeLayerId } = useFlowStore.getState();
+ const newTextNode = createPastedTextNode(pastedText, pasteFlowPosition, activeLayerId);
+
+ setNodes((existingNodes) => [
+ ...existingNodes.map((node) => ({ ...node, selected: false })),
+ { ...newTextNode, selected: true },
+ ]);
+ setSelectedNodeId(newTextNode.id);
+ },
+ [
+ activeTabId,
+ addToast,
+ architectureStrictMode,
+ clearMermaidDiagnostics,
+ fitView,
+ getCanvasCenterFlowPosition,
+ pasteSelection,
+ getLastInteractionFlowPosition,
+ recordHistory,
+ setEdges,
+ setMermaidDiagnostics,
+ setNodes,
+ setSelectedNodeId,
+ safelyEnrichImportedNodes,
+ strictModePasteBlockedMessage,
+ updateTab,
+ ]
+ );
+
+ return {
+ handleCanvasPaste,
+ };
}
diff --git a/src/components/flow-editor/useFlowEditorController.ts b/src/components/flow-editor/useFlowEditorController.ts
index 4d94113c..7acee867 100644
--- a/src/components/flow-editor/useFlowEditorController.ts
+++ b/src/components/flow-editor/useFlowEditorController.ts
@@ -372,6 +372,7 @@ export function useFlowEditorController({
return {
shouldRenderPanels,
handleCanvasEntityIntent,
+ openStudioCode,
panels,
chrome,
};
diff --git a/src/components/properties/BulkNodeProperties.tsx b/src/components/properties/BulkNodeProperties.tsx
index 1b9945ab..34cc961e 100644
--- a/src/components/properties/BulkNodeProperties.tsx
+++ b/src/components/properties/BulkNodeProperties.tsx
@@ -42,6 +42,11 @@ import {
type BulkSectionId,
type BulkNodePropertiesFormState,
} from './bulkNodePropertiesModel';
+import {
+ createBuiltInIconData,
+ createProviderIconData,
+ createUploadedIconData,
+} from '@/lib/nodeIconState';
interface BulkNodePropertiesProps {
selectedNodes: Node[];
@@ -138,41 +143,49 @@ export function BulkNodeProperties({
}
function handleBuiltInIconChange(nextIcon: string): void {
+ const updates = createBuiltInIconData(nextIcon);
setForm((current) => ({
...current,
iconMode: 'built-in',
- icon: nextIcon,
- customIconUrl: undefined,
- assetProvider: undefined,
- assetCategory: undefined,
- archIconPackId: undefined,
- archIconShapeId: undefined,
+ icon: updates.icon ?? '',
+ customIconUrl: updates.customIconUrl,
+ assetProvider: updates.assetProvider as BulkNodePropertiesFormState['assetProvider'],
+ assetCategory: updates.assetCategory,
+ archIconPackId: updates.archIconPackId,
+ archIconShapeId: updates.archIconShapeId,
}));
}
function handleProviderIconChange(selection: ProviderIconSelection): void {
+ const updates = createProviderIconData({
+ packId: selection.packId,
+ shapeId: selection.shapeId,
+ provider: selection.provider,
+ category: selection.category,
+ });
setForm((current) => ({
...current,
iconMode: 'provider',
- icon: '',
- customIconUrl: undefined,
- assetProvider: selection.provider,
- assetCategory: selection.category,
- archIconPackId: selection.packId,
- archIconShapeId: selection.shapeId,
+ icon: updates.icon ?? '',
+ customIconUrl: updates.customIconUrl,
+ assetProvider: updates.assetProvider as BulkNodePropertiesFormState['assetProvider'],
+ assetCategory: updates.assetCategory,
+ archIconPackId: updates.archIconPackId,
+ archIconShapeId: updates.archIconShapeId,
}));
}
function handleCustomIconChange(url?: string): void {
+ const updates = createUploadedIconData(url);
setForm((current) => ({
...current,
iconMode: url ? 'upload' : '',
- icon: '',
- customIconUrl: url,
- assetProvider: undefined,
- assetCategory: undefined,
- archIconPackId: undefined,
- archIconShapeId: undefined,
+ icon: updates.icon ?? '',
+ customIconUrl: updates.customIconUrl,
+ assetProvider: updates.assetProvider as BulkNodePropertiesFormState['assetProvider'],
+ assetCategory: updates.assetCategory,
+ archIconPackId: updates.archIconPackId,
+ archIconShapeId: updates.archIconShapeId,
}));
}
diff --git a/src/components/properties/IconPicker.tsx b/src/components/properties/IconPicker.tsx
index ef8f8802..36c52e09 100644
--- a/src/components/properties/IconPicker.tsx
+++ b/src/components/properties/IconPicker.tsx
@@ -8,6 +8,7 @@ import {
loadProviderShapePreview,
} from '@/services/shapeLibrary/providerCatalog';
import { useAssetCatalog } from '@/hooks/useAssetCatalog';
+import { inferAssetProviderFromPackId } from '@/lib/nodeIconState';
import { ICON_NAMES, ICON_PICKER_PRIORITY_NAMES, NamedIcon } from '../IconMap';
import { Tooltip } from '../Tooltip';
import { Select } from '../ui/Select';
@@ -72,16 +73,6 @@ function getInitialSource(
return 'built-in';
}
-function inferProviderFromPackId(packId: string | undefined): DomainLibraryCategory | undefined {
- if (!packId) {
- return undefined;
- }
-
- const normalizedPackId = packId.toLowerCase();
- const match = PROVIDER_OPTIONS.find((option) => normalizedPackId.includes(option.value));
- return match?.value as DomainLibraryCategory | undefined;
-}
-
function getProviderLabel(provider: DomainLibraryCategory): string {
return getAssetCategoryDisplayName(provider);
}
@@ -103,7 +94,7 @@ export const IconPicker: React.FC = ({
);
const [provider, setProvider] = useState(
selectedProvider
- ?? inferProviderFromPackId(selectedProviderPackId)
+ ?? inferAssetProviderFromPackId(selectedProviderPackId)
?? (PROVIDER_OPTIONS[0]?.value as DomainLibraryCategory)
?? 'aws'
);
@@ -117,7 +108,7 @@ export const IconPicker: React.FC = ({
setProvider(selectedProvider);
return;
}
- const inferredProvider = inferProviderFromPackId(selectedProviderPackId);
+ const inferredProvider = inferAssetProviderFromPackId(selectedProviderPackId);
if (inferredProvider) {
setProvider(inferredProvider);
}
diff --git a/src/components/properties/NodeProperties.test.tsx b/src/components/properties/NodeProperties.test.tsx
index 5cd9121b..81d5acda 100644
--- a/src/components/properties/NodeProperties.test.tsx
+++ b/src/components/properties/NodeProperties.test.tsx
@@ -58,4 +58,26 @@ describe('NodeProperties', () => {
expect(screen.getByText('Secondary Style')).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Text Style' })).toBeNull();
});
+
+ it('uses the shared icon picker for icon-backed asset nodes', () => {
+ render(
+
+ );
+
+ expect(screen.getByRole('button', { name: 'Icon' })).toHaveAttribute('aria-expanded', 'true');
+ expect(screen.getByText('icon-picker')).toBeTruthy();
+ });
});
diff --git a/src/components/properties/NodeProperties.tsx b/src/components/properties/NodeProperties.tsx
index b567ce27..03ef3a74 100644
--- a/src/components/properties/NodeProperties.tsx
+++ b/src/components/properties/NodeProperties.tsx
@@ -9,20 +9,19 @@ import { IconPicker, type ProviderIconSelection } from './IconPicker';
import { ImageUpload } from './ImageUpload';
import { CollapsibleSection } from '../ui/CollapsibleSection';
import { useMarkdownEditor } from '@/hooks/useMarkdownEditor';
-import { useAssetCatalog } from '@/hooks/useAssetCatalog';
import { NodeActionButtons } from './NodeActionButtons';
import { NodeContentSection } from './NodeContentSection';
import { NodeImageSettingsSection } from './NodeImageSettingsSection';
import { NodeWireframeVariantSection } from './NodeWireframeVariantSection';
import { InspectorSectionDivider } from './InspectorPrimitives';
-import { Tooltip } from '../Tooltip';
-import { Select } from '../ui/Select';
import type { DomainLibraryCategory } from '@/services/domainLibrary';
-import { getAssetCategoryDisplayName, getAssetCategoryNoun } from '@/services/assetPresentation';
-import { loadProviderShapePreview } from '@/services/shapeLibrary/providerCatalog';
-import { NamedIcon } from '../IconMap';
-import { createPropertyInputKeyDownHandler } from './propertyInputBehavior';
-import { IconSearchField, IconTileScrollGrid } from './IconTilePickerPrimitives';
+import { getAssetCategoryDisplayName } from '@/services/assetPresentation';
+import {
+ createBuiltInIconData,
+ createProviderIconData,
+ createUploadedIconData,
+ normalizeNodeIconData,
+} from '@/lib/nodeIconState';
import { getNodeParentId } from '@/lib/nodeParent';
import { buildSectionActions } from './sectionActionBuilder';
@@ -52,8 +51,13 @@ export const NodeProperties: React.FC = ({
const isSection = selectedNode.type === 'section';
const isGroup = selectedNode.type === 'group';
const isWireframeApp = selectedNode.type === 'browser' || selectedNode.type === 'mobile';
- const isIconAssetNode = selectedNode.data?.assetPresentation === 'icon';
- const assetProvider = (selectedNode.data?.assetProvider || '') as DomainLibraryCategory;
+ const normalizedIconData = normalizeNodeIconData(selectedNode.data);
+ const isIconAssetNode = normalizedIconData?.assetPresentation === 'icon';
+ const assetProvider = normalizedIconData?.assetProvider as DomainLibraryCategory | undefined;
+ const assetCategory =
+ typeof normalizedIconData?.assetCategory === 'string'
+ ? normalizedIconData.assetCategory
+ : undefined;
const supportsAdvancedColorTheme = ['process', 'start', 'end', 'decision', 'custom'].includes(
selectedNode.type || ''
);
@@ -73,34 +77,10 @@ export const NodeProperties: React.FC = ({
onChange,
});
- const {
- items: assetItems,
- filteredItems: filteredAssetItems,
- previewUrls: assetPreviewUrls,
- query: assetQuery,
- setQuery: setAssetQuery,
- category: assetFilterCategory,
- setCategory: setAssetFilterCategory,
- } = useAssetCatalog({
- provider: assetProvider,
- });
- const assetCategories = React.useMemo(
- () =>
- Array.from(
- new Set(
- assetItems
- .map((item) => item.providerShapeCategory)
- .filter((value): value is string => Boolean(value))
- )
- ).sort((left, right) => left.localeCompare(right)),
- [assetItems]
- );
- const handlePropertyInputKeyDown = createPropertyInputKeyDownHandler({ blurOnEnter: true });
-
function getDefaultSection(): string {
if (isImage) return 'image';
if (isWireframeApp) return 'variant';
- if (isIconAssetNode) return 'asset';
+ if (isIconAssetNode) return 'icon';
if (isSection) return 'content';
if (isText || isAnnotation) return 'content';
return 'content';
@@ -155,36 +135,23 @@ export const NodeProperties: React.FC = ({
}
function handleBuiltInIconChange(icon: string): void {
- onChange(selectedNode.id, {
- icon,
- customIconUrl: undefined,
- archIconPackId: undefined,
- archIconShapeId: undefined,
- assetProvider: undefined,
- assetCategory: undefined,
- });
+ onChange(selectedNode.id, createBuiltInIconData(icon));
}
function handleProviderIconChange(selection: ProviderIconSelection): void {
- onChange(selectedNode.id, {
- icon: undefined,
- customIconUrl: undefined,
- archIconPackId: selection.packId,
- archIconShapeId: selection.shapeId,
- assetProvider: selection.provider,
- assetCategory: selection.category,
- });
+ onChange(
+ selectedNode.id,
+ createProviderIconData({
+ packId: selection.packId,
+ shapeId: selection.shapeId,
+ provider: selection.provider,
+ category: selection.category,
+ })
+ );
}
function handleCustomIconChange(url?: string): void {
- onChange(selectedNode.id, {
- icon: undefined,
- customIconUrl: url,
- archIconPackId: undefined,
- archIconShapeId: undefined,
- assetProvider: undefined,
- assetCategory: undefined,
- });
+ onChange(selectedNode.id, createUploadedIconData(url));
}
return (
@@ -231,84 +198,6 @@ export const NodeProperties: React.FC = ({
/>
)}
- {isIconAssetNode && (
- }
- isOpen={activeSection === 'asset' || activeSection === 'shape'}
- onToggle={() => toggleSection('asset')}
- >
-
-
setAssetQuery(event.target.value)}
- onKeyDown={handlePropertyInputKeyDown}
- placeholder={`Search ${getAssetCategoryDisplayName(assetProvider || 'icons').toLowerCase()} ${getAssetCategoryNoun(assetProvider || 'icons')}`}
- />
- {assetCategories.length > 1 ? (
- setAssetFilterCategory(value)}
- options={[
- { value: 'all', label: 'All categories' },
- ...assetCategories.map((category) => ({ value: category, label: category })),
- ]}
- placeholder="All categories"
- />
- ) : null}
-
- {filteredAssetItems.map((item) => (
-
- {
- const preview =
- item.archIconPackId && item.archIconShapeId
- ? await loadProviderShapePreview(
- item.archIconPackId,
- item.archIconShapeId
- )
- : null;
- onChange(selectedNode.id, {
- label: item.label,
- icon: item.icon,
- customIconUrl: preview?.previewUrl,
- archIconPackId: item.archIconPackId,
- archIconShapeId: item.archIconShapeId,
- assetProvider: item.category,
- assetCategory: item.providerShapeCategory,
- });
- }}
- >
- {assetPreviewUrls[item.id] ? (
-
- ) : item.category === 'icons' ? (
-
- ) : (
-
- )}
-
-
- ))}
-
-
-
- )}
-
{/* Content Section: Refined Design */}
= ({
)}
- {!isAnnotation && !isText && !isImage && !isWireframeApp && !isIconAssetNode && (
+ {!isAnnotation && !isText && !isImage && !isWireframeApp && (
}
isOpen={activeSection === 'icon'}
onToggle={() => toggleSection('icon')}
>
-
+
+
+ {isIconAssetNode && (assetProvider || assetCategory) ? (
+
+
+ {assetProvider ? getAssetCategoryDisplayName(assetProvider) : 'Icons'}
+ {assetCategory ? ` โข ${assetCategory}` : ''}
+
+
+ ) : null}
+
)}
diff --git a/src/components/properties/bulkNodePropertiesModel.ts b/src/components/properties/bulkNodePropertiesModel.ts
index b755f777..0580b7f0 100644
--- a/src/components/properties/bulkNodePropertiesModel.ts
+++ b/src/components/properties/bulkNodePropertiesModel.ts
@@ -2,6 +2,11 @@ import type { Node } from '@/lib/reactflowCompat';
import type { NodeData } from '@/lib/types';
import type { DomainLibraryCategory } from '@/services/domainLibrary';
import { getCapabilityTargetNodeIds, type BulkLabelTransformOptions } from '@/lib/nodeBulkEditing';
+import {
+ createBuiltInIconData,
+ createProviderIconData,
+ createUploadedIconData,
+} from '@/lib/nodeIconState';
export type BulkIconMode = '' | 'built-in' | 'provider' | 'upload';
export type BulkSectionId =
@@ -155,30 +160,25 @@ export function buildBulkUpdates(
}
if (form.iconMode === 'built-in') {
- updates.icon = form.icon;
- updates.customIconUrl = undefined;
- updates.assetProvider = undefined;
- updates.assetCategory = undefined;
- updates.archIconPackId = undefined;
- updates.archIconShapeId = undefined;
+ Object.assign(updates, createBuiltInIconData(form.icon));
}
if (form.iconMode === 'upload') {
- updates.icon = undefined;
- updates.customIconUrl = form.customIconUrl;
- updates.assetProvider = undefined;
- updates.assetCategory = undefined;
- updates.archIconPackId = undefined;
- updates.archIconShapeId = undefined;
+ Object.assign(updates, createUploadedIconData(form.customIconUrl));
}
if (form.iconMode === 'provider') {
- updates.icon = undefined;
- updates.customIconUrl = undefined;
- updates.assetProvider = form.assetProvider;
- updates.assetCategory = form.assetCategory;
- updates.archIconPackId = form.archIconPackId;
- updates.archIconShapeId = form.archIconShapeId;
+ if (form.archIconPackId && form.archIconShapeId) {
+ Object.assign(
+ updates,
+ createProviderIconData({
+ packId: form.archIconPackId,
+ shapeId: form.archIconShapeId,
+ provider: form.assetProvider,
+ category: form.assetCategory,
+ })
+ );
+ }
}
if (form.archEnvironment) {
diff --git a/src/components/properties/families/ArchitectureNodeProperties.test.tsx b/src/components/properties/families/ArchitectureNodeProperties.test.tsx
index dc85b8f0..396af54b 100644
--- a/src/components/properties/families/ArchitectureNodeProperties.test.tsx
+++ b/src/components/properties/families/ArchitectureNodeProperties.test.tsx
@@ -3,11 +3,16 @@ import { describe, expect, it, vi, beforeEach } from 'vitest';
import { useFlowStore } from '@/store';
import { ArchitectureNodeProperties } from './ArchitectureNodeProperties';
-vi.mock('@/services/shapeLibrary/providerCatalog', () => ({
- loadProviderCatalog: vi.fn().mockImplementation(() => new Promise(() => {})),
- loadProviderShapePreview: vi.fn().mockResolvedValue(null),
- listProviderCatalogProviders: vi.fn().mockReturnValue([]),
-}));
+vi.mock('@/services/shapeLibrary/providerCatalog', async (importOriginal) => {
+ const actual = await importOriginal();
+
+ return {
+ ...actual,
+ loadProviderCatalog: vi.fn().mockImplementation(() => new Promise(() => {})),
+ loadProviderShapePreview: vi.fn().mockResolvedValue(null),
+ listProviderCatalogProviders: vi.fn().mockReturnValue([]),
+ };
+});
const baseHandlers = {
onChange: vi.fn(),
diff --git a/src/components/properties/families/ArchitectureNodeSection.tsx b/src/components/properties/families/ArchitectureNodeSection.tsx
index 92908118..0b038f5d 100644
--- a/src/components/properties/families/ArchitectureNodeSection.tsx
+++ b/src/components/properties/families/ArchitectureNodeSection.tsx
@@ -3,6 +3,7 @@ import type { NodeData } from '@/lib/types';
import type { DomainLibraryCategory, DomainLibraryItem } from '@/services/domainLibrary';
import { loadProviderCatalog } from '@/services/shapeLibrary/providerCatalog';
import { useAssetCatalog } from '@/hooks/useAssetCatalog';
+import { createProviderIconData, createUploadedIconData } from '@/lib/nodeIconState';
import { InspectorField } from '@/components/properties/InspectorPrimitives';
import { SegmentedChoice } from '@/components/properties/SegmentedChoice';
import { Input } from '@/components/ui/Input';
@@ -81,13 +82,19 @@ export function ArchitectureNodeSection({
onChange(nodeId, {
label: item.label,
subLabel: item.providerShapeCategory || item.description,
- icon: item.icon,
archProvider: item.category,
archProviderLabel: undefined,
archResourceType: 'service',
- customIconUrl: undefined,
- archIconPackId: item.archIconPackId,
- archIconShapeId: item.archIconShapeId,
+ ...(
+ item.archIconPackId && item.archIconShapeId
+ ? createProviderIconData({
+ packId: item.archIconPackId,
+ shapeId: item.archIconShapeId,
+ provider: item.category,
+ category: item.providerShapeCategory,
+ })
+ : createUploadedIconData(undefined)
+ ),
});
}
@@ -98,7 +105,7 @@ export function ArchitectureNodeSection({
}
readFileAsDataUrl(file, (result) => {
- onChange(nodeId, { customIconUrl: result });
+ onChange(nodeId, createUploadedIconData(result));
});
}
diff --git a/src/components/studio-code-panel/useStudioCodePanelController.test.tsx b/src/components/studio-code-panel/useStudioCodePanelController.test.tsx
index e6090e47..a2d73b1c 100644
--- a/src/components/studio-code-panel/useStudioCodePanelController.test.tsx
+++ b/src/components/studio-code-panel/useStudioCodePanelController.test.tsx
@@ -115,4 +115,37 @@ describe('useStudioCodePanelController', () => {
expect(result.current.hasDraftChanges).toBe(false);
expect(result.current.draftPreview.state).toBe('ready');
});
+
+ it('surfaces partially editable Mermaid drafts as ready with warnings', () => {
+ const props = createBaseProps({ mode: 'mermaid' });
+ parseMermaidByTypeMock.mockReturnValue({
+ nodes: [{ id: 'a' }],
+ edges: [{ id: 'e' }],
+ diagramType: 'flowchart',
+ importState: 'editable_partial',
+ });
+
+ const { result } = renderHook(() => useStudioCodePanelController(props));
+
+ expect(result.current.draftPreview.state).toBe('ready');
+ expect(result.current.draftPreview.label).toBe('Ready with warnings');
+ expect(result.current.draftPreview.detail).toContain('partial editability');
+ });
+
+ it('surfaces unsupported Mermaid families with fallback guidance', () => {
+ const props = createBaseProps({ mode: 'mermaid' });
+ parseMermaidByTypeMock.mockReturnValue({
+ nodes: [],
+ edges: [],
+ diagramType: undefined,
+ importState: 'unsupported_family',
+ error: 'Mermaid "gitGraph" is not supported yet in editable mode.',
+ });
+
+ const { result } = renderHook(() => useStudioCodePanelController(props));
+
+ expect(result.current.draftPreview.state).toBe('error');
+ expect(result.current.draftPreview.label).toBe('Unsupported Mermaid family');
+ expect(result.current.draftPreview.detail).toContain('not editable yet');
+ });
});
diff --git a/src/components/studio-code-panel/useStudioCodePanelController.ts b/src/components/studio-code-panel/useStudioCodePanelController.ts
index 77d8d938..9193c444 100644
--- a/src/components/studio-code-panel/useStudioCodePanelController.ts
+++ b/src/components/studio-code-panel/useStudioCodePanelController.ts
@@ -8,6 +8,12 @@ import type { FlowEdge, FlowNode } from '@/lib/types';
import { applyCodeChanges } from '@/components/command-bar/applyCodeChanges';
import type { StudioCodeMode } from '@/hooks/useFlowEditorUIState';
import type { MermaidDiagnosticsSnapshot } from '@/store/types';
+import type { MermaidDispatchParseResult } from '@/services/mermaid/parseMermaidByType';
+import {
+ appendMermaidImportGuidance,
+ getMermaidImportStateDetail,
+ getMermaidImportStateLabel,
+} from '@/services/mermaid/importStatePresentation';
export type DraftPreviewState = 'empty' | 'error' | 'ready';
@@ -17,6 +23,19 @@ export interface DraftPreview {
detail: string;
}
+function buildMermaidDraftPreviewDetail(parsed: MermaidDispatchParseResult): DraftPreview {
+ return {
+ state: 'ready',
+ label: getMermaidImportStateLabel(parsed.importState),
+ detail: getMermaidImportStateDetail({
+ importState: parsed.importState,
+ diagramType: parsed.diagramType,
+ nodeCount: parsed.nodes.length,
+ edgeCount: parsed.edges.length,
+ }),
+ };
+}
+
interface UseStudioCodePanelControllerParams {
nodes: FlowNode[];
edges: FlowEdge[];
@@ -96,16 +115,16 @@ export function useStudioCodePanelController({
if (parsed.error) {
return {
state: 'error',
- label: 'Needs fixes',
- detail: parsed.error,
+ label: getMermaidImportStateLabel(parsed.importState),
+ detail: appendMermaidImportGuidance({
+ message: parsed.error,
+ importState: parsed.importState,
+ diagramType: parsed.diagramType,
+ }),
};
}
- return {
- state: 'ready',
- label: 'Ready to apply',
- detail: `${parsed.nodes.length} nodes, ${parsed.edges.length} edges`,
- };
+ return buildMermaidDraftPreviewDetail(parsed);
}
const parsed = parseOpenFlowDSL(code);
diff --git a/src/config/aiProviders.ts b/src/config/aiProviders.ts
index 86093b56..3a6b6eb5 100644
--- a/src/config/aiProviders.ts
+++ b/src/config/aiProviders.ts
@@ -14,10 +14,10 @@ export const DEFAULT_MODELS: Record = {
openai: 'gpt-5-mini',
claude: 'claude-sonnet-4-6',
groq: 'meta-llama/llama-4-scout-17b-16e-instruct',
- nvidia: 'meta/llama-4-scout-17b-16e-instruct',
+ nvidia: 'meta/llama-4-maverick-17b-128e-instruct',
cerebras: 'gpt-oss-120b',
- mistral: 'mistral-medium-latest',
- openrouter: 'google/gemini-2.5-flash',
+ mistral: 'mistral-large-latest',
+ openrouter: 'google/gemini-2.5-pro',
custom: 'gpt-4o',
};
diff --git a/src/config/rolloutFlags.ts b/src/config/rolloutFlags.ts
index dd5b2c1a..712388b9 100644
--- a/src/config/rolloutFlags.ts
+++ b/src/config/rolloutFlags.ts
@@ -1,65 +1,97 @@
export type RolloutFlagKey =
- | 'relationSemanticsV1'
- | 'documentModelV2'
- | 'collaborationEnabled'
- | 'architectureLintEnabled';
+ | 'relationSemanticsV1'
+ | 'documentModelV2'
+ | 'collaborationEnabled'
+ | 'architectureLintEnabled'
+ | 'importSql'
+ | 'importOpenApi'
+ | 'importInfraTerraformHcl'
+ | 'importCodebase';
interface RolloutFlagDefinition {
- key: RolloutFlagKey;
- envVar: string;
- defaultEnabled: boolean;
- description: string;
+ key: RolloutFlagKey;
+ envVar: string;
+ defaultEnabled: boolean;
+ description: string;
}
const ROLLOUT_FLAG_DEFINITIONS: Record = {
- relationSemanticsV1: {
- key: 'relationSemanticsV1',
- envVar: 'VITE_RELATION_SEMANTICS_V1',
- defaultEnabled: false,
- description: 'Class/ER relation marker and routing semantics rollout',
- },
- documentModelV2: {
- key: 'documentModelV2',
- envVar: 'VITE_DOCUMENT_MODEL_V2',
- defaultEnabled: false,
- description: 'Extended document metadata for scenes, exports, and bindings',
- },
- collaborationEnabled: {
- key: 'collaborationEnabled',
- envVar: 'VITE_COLLABORATION_ENABLED',
- defaultEnabled: true,
- description: 'WebRTC peer collaboration (beta)',
- },
- architectureLintEnabled: {
- key: 'architectureLintEnabled',
- envVar: 'VITE_ARCHITECTURE_LINT_ENABLED',
- defaultEnabled: true,
- description: 'Architecture diagram lint rules panel',
- },
+ relationSemanticsV1: {
+ key: 'relationSemanticsV1',
+ envVar: 'VITE_RELATION_SEMANTICS_V1',
+ defaultEnabled: false,
+ description: 'Class/ER relation marker and routing semantics rollout',
+ },
+ documentModelV2: {
+ key: 'documentModelV2',
+ envVar: 'VITE_DOCUMENT_MODEL_V2',
+ defaultEnabled: false,
+ description: 'Extended document metadata for scenes, exports, and bindings',
+ },
+ collaborationEnabled: {
+ key: 'collaborationEnabled',
+ envVar: 'VITE_COLLABORATION_ENABLED',
+ defaultEnabled: true,
+ description: 'WebRTC peer collaboration (beta)',
+ },
+ architectureLintEnabled: {
+ key: 'architectureLintEnabled',
+ envVar: 'VITE_ARCHITECTURE_LINT_ENABLED',
+ defaultEnabled: true,
+ description: 'Architecture diagram lint rules panel',
+ },
+ importSql: {
+ key: 'importSql',
+ envVar: 'VITE_IMPORT_SQL',
+ defaultEnabled: false,
+ description: 'SQL DDL importer (hidden โ unreliable for complex schemas)',
+ },
+ importOpenApi: {
+ key: 'importOpenApi',
+ envVar: 'VITE_IMPORT_OPENAPI',
+ defaultEnabled: false,
+ description: 'OpenAPI/Swagger importer (hidden โ JSON-only, no YAML)',
+ },
+ importInfraTerraformHcl: {
+ key: 'importInfraTerraformHcl',
+ envVar: 'VITE_IMPORT_INFRA_TERRAFORM_HCL',
+ defaultEnabled: false,
+ description: 'Terraform HCL importer (hidden โ AI-only, hallucination-prone)',
+ },
+ importCodebase: {
+ key: 'importCodebase',
+ envVar: 'VITE_IMPORT_CODEBASE',
+ defaultEnabled: false,
+ description: 'Repo/codebase analyzer importer (hidden โ niche, heavy)',
+ },
};
function readBooleanEnvFlag(envValue: string | undefined, defaultEnabled: boolean): boolean {
- if (envValue === '1') {
- return true;
- }
- if (envValue === '0') {
- return false;
- }
- return defaultEnabled;
+ if (envValue === '1') {
+ return true;
+ }
+ if (envValue === '0') {
+ return false;
+ }
+ return defaultEnabled;
}
export function isRolloutFlagEnabled(key: RolloutFlagKey): boolean {
- const definition = ROLLOUT_FLAG_DEFINITIONS[key];
- if (!definition.envVar) {
- return definition.defaultEnabled;
- }
- const envValue = import.meta.env[definition.envVar as keyof ImportMetaEnv] as string | undefined;
- return readBooleanEnvFlag(envValue, definition.defaultEnabled);
+ const definition = ROLLOUT_FLAG_DEFINITIONS[key];
+ if (!definition.envVar) {
+ return definition.defaultEnabled;
+ }
+ const envValue = import.meta.env[definition.envVar as keyof ImportMetaEnv] as string | undefined;
+ return readBooleanEnvFlag(envValue, definition.defaultEnabled);
}
export const ROLLOUT_FLAGS: Record = {
- relationSemanticsV1: isRolloutFlagEnabled('relationSemanticsV1'),
- documentModelV2: isRolloutFlagEnabled('documentModelV2'),
- collaborationEnabled: isRolloutFlagEnabled('collaborationEnabled'),
- architectureLintEnabled: isRolloutFlagEnabled('architectureLintEnabled'),
+ relationSemanticsV1: isRolloutFlagEnabled('relationSemanticsV1'),
+ documentModelV2: isRolloutFlagEnabled('documentModelV2'),
+ collaborationEnabled: isRolloutFlagEnabled('collaborationEnabled'),
+ architectureLintEnabled: isRolloutFlagEnabled('architectureLintEnabled'),
+ importSql: isRolloutFlagEnabled('importSql'),
+ importOpenApi: isRolloutFlagEnabled('importOpenApi'),
+ importInfraTerraformHcl: isRolloutFlagEnabled('importInfraTerraformHcl'),
+ importCodebase: isRolloutFlagEnabled('importCodebase'),
};
diff --git a/src/diagram-types/architecture/plugin.test.ts b/src/diagram-types/architecture/plugin.test.ts
index 73838a92..4c80e5b0 100644
--- a/src/diagram-types/architecture/plugin.test.ts
+++ b/src/diagram-types/architecture/plugin.test.ts
@@ -39,6 +39,7 @@ describe('ARCHITECTURE_PLUGIN', () => {
const result = ARCHITECTURE_PLUGIN.parseMermaid(input);
expect(result.error).toBeUndefined();
+ expect(result.nodes.some((node) => node.data.archTitle === 'Platform')).toBe(true);
expect(result.nodes.some((node) => node.id === 'api.gateway')).toBe(true);
expect(result.nodes.some((node) => node.data.label === 'API Gateway')).toBe(true);
expect(result.edges).toHaveLength(1);
@@ -52,6 +53,22 @@ describe('ARCHITECTURE_PLUGIN', () => {
expect(result.edges[0].targetHandle).toBe('left');
});
+ it('preserves nested architecture groups and parented group metadata', () => {
+ const input = `
+ architecture-beta
+ group global[Global]
+ group prod(cloud)[Prod] in global
+ service api(server)[API] in prod
+ `;
+
+ const result = ARCHITECTURE_PLUGIN.parseMermaid(input);
+ expect(result.error).toBeUndefined();
+ expect(result.nodes.find((node) => node.id === 'prod')?.parentId).toBe('global');
+ expect(result.nodes.find((node) => node.id === 'prod')?.data.archBoundaryId).toBe('global');
+ expect(result.nodes.find((node) => node.id === 'prod')?.data.archProvider).toBe('cloud');
+ expect(result.nodes.find((node) => node.id === 'api')?.parentId).toBe('prod');
+ });
+
it('extracts protocol and port metadata when label follows protocol:port format', () => {
const input = `
architecture-beta
@@ -68,6 +85,25 @@ describe('ARCHITECTURE_PLUGIN', () => {
expect(result.edges[0].data?.archPort).toBe('443');
});
+ it('parses official Mermaid architecture edge syntax without labels', () => {
+ const input = `
+ architecture-beta
+ service api(server)[API]
+ service db(database)[Database]
+ api:R --> L:db
+ `;
+
+ const result = ARCHITECTURE_PLUGIN.parseMermaid(input);
+ expect(result.error).toBeUndefined();
+ expect(result.edges).toHaveLength(1);
+ expect(result.edges[0].source).toBe('api');
+ expect(result.edges[0].target).toBe('db');
+ expect(result.edges[0].label).toBeUndefined();
+ expect(result.edges[0].data?.archDirection).toBe('-->');
+ expect(result.edges[0].data?.archSourceSide).toBe('R');
+ expect(result.edges[0].data?.archTargetSide).toBe('L');
+ });
+
it('parses reverse and bidirectional direction tokens', () => {
const input = `
architecture-beta
diff --git a/src/diagram-types/architecture/plugin.ts b/src/diagram-types/architecture/plugin.ts
index 3bb95740..cbbb3bac 100644
--- a/src/diagram-types/architecture/plugin.ts
+++ b/src/diagram-types/architecture/plugin.ts
@@ -24,6 +24,38 @@ interface ParsedArchEdge {
targetSide?: 'L' | 'R' | 'T' | 'B';
}
+function buildArchitectureLayerRanks(nodes: ParsedArchNode[]): Map {
+ const ranks = new Map();
+ let nextRank = 0;
+
+ for (const node of nodes) {
+ if (node.kind === 'group' && !node.parentId && !ranks.has(node.id)) {
+ ranks.set(node.id, nextRank++);
+ }
+ }
+
+ for (const node of nodes) {
+ if (node.kind !== 'group' && !node.parentId && !ranks.has(node.id)) {
+ ranks.set(node.id, nextRank++);
+ }
+ }
+
+ return ranks;
+}
+
+function resolveArchitectureLayerRank(
+ node: ParsedArchNode,
+ layerRanks: Map
+): number | undefined {
+ if (layerRanks.has(node.id)) {
+ return layerRanks.get(node.id);
+ }
+ if (node.parentId && layerRanks.has(node.parentId)) {
+ return layerRanks.get(node.parentId);
+ }
+ return undefined;
+}
+
function sideToHandleId(side: ParsedArchEdge['sourceSide']): string | undefined {
if (side === 'L') return 'left';
if (side === 'R') return 'right';
@@ -190,12 +222,12 @@ function parseArchitecture(input: string): { nodes: FlowNode[]; edges: FlowEdge[
const nodeFirstDefinedAt = new Map();
const diagnostics: string[] = [];
let hasHeader = false;
+ let title: string | undefined;
for (const [index, rawLine] of lines.entries()) {
const lineNumber = index + 1;
const line = rawLine.trim();
if (!line || line.startsWith('%%')) continue;
- if (/^title\b/i.test(line)) continue;
if (/^architecture(?:-beta)?\b/i.test(line)) {
hasHeader = true;
@@ -203,6 +235,15 @@ function parseArchitecture(input: string): { nodes: FlowNode[]; edges: FlowEdge[
}
if (!hasHeader) continue;
+ const titleMatch = line.match(/^title\s+(.+)$/i);
+ if (titleMatch) {
+ const nextTitle = stripQuotes(titleMatch[1]);
+ if (nextTitle) {
+ title = nextTitle;
+ }
+ continue;
+ }
+
const node = parseNodeLine(line);
if (node) {
if (knownNodeIds.has(node.id)) {
@@ -257,7 +298,9 @@ function parseArchitecture(input: string): { nodes: FlowNode[]; edges: FlowEdge[
}
const nodeIds = new Set(parsedNodes.map((node) => node.id));
+ const layerRanks = buildArchitectureLayerRanks(parsedNodes);
const nodes: FlowNode[] = parsedNodes.map((node, index) => {
+ const layerRank = resolveArchitectureLayerRank(node, layerRanks);
let mappedNode: FlowNode = {
id: node.id,
type: 'architecture',
@@ -270,9 +313,12 @@ function parseArchitecture(input: string): { nodes: FlowNode[]; edges: FlowEdge[
color: resolveArchKindColor(node.kind),
shape: node.kind === 'group' ? 'rounded' : 'rectangle',
icon: resolveArchKindIcon(node.kind),
+ archTitle: index === 0 ? title : undefined,
archProvider: node.icon || (node.kind === 'group' ? 'group' : 'custom'),
archResourceType: node.kind,
archBoundaryId: node.parentId,
+ archLayerRank: layerRank,
+ archLayerLabel: node.parentId || node.id,
},
};
diff --git a/src/diagram-types/classDiagram/plugin.test.ts b/src/diagram-types/classDiagram/plugin.test.ts
index e0e8fa16..45468caf 100644
--- a/src/diagram-types/classDiagram/plugin.test.ts
+++ b/src/diagram-types/classDiagram/plugin.test.ts
@@ -46,6 +46,41 @@ describe('CLASS_DIAGRAM_PLUGIN', () => {
expect(result.nodes.find((node) => node.id === 'Domain.Account')?.data.classMethods).toContain('+balance(): Money');
});
+ it('normalizes generic class identifiers and preserves relation cardinality metadata', () => {
+ const input = `
+ classDiagram
+ class Repository~T~ {
+ +findById(id: UUID): T
+ }
+ class User
+ Repository~T~ "1" --> "*" User : stores
+ `;
+
+ const result = CLASS_DIAGRAM_PLUGIN.parseMermaid(input);
+ expect(result.error).toBeUndefined();
+ expect(result.nodes.find((node) => node.id === 'Repository')?.data.label).toBe('Repository');
+ expect(result.edges[0].data.classRelationSourceCardinality).toBe('1');
+ expect(result.edges[0].data.classRelationTargetCardinality).toBe('*');
+ });
+
+ it('parses multi-parameter generic identifiers with Mermaid ~...~ syntax', () => {
+ const input = `
+ classDiagram
+ class Map~K, V~
+ class Entry
+ Map~K, V~ --> Entry : stores
+ `;
+
+ const result = CLASS_DIAGRAM_PLUGIN.parseMermaid(input);
+ expect(result.error).toBeUndefined();
+ expect(result.nodes.find((node) => node.id === 'Map')?.data.label).toBe('Map');
+ expect(result.edges[0]).toMatchObject({
+ source: 'Map',
+ target: 'Entry',
+ });
+ expect(result.edges[0].data.classRelationLabel).toBe('stores');
+ });
+
it('emits diagnostics for malformed class lines and relation syntax', () => {
const input = `
classDiagram
diff --git a/src/diagram-types/classDiagram/plugin.ts b/src/diagram-types/classDiagram/plugin.ts
index 35972de5..f6f7fe69 100644
--- a/src/diagram-types/classDiagram/plugin.ts
+++ b/src/diagram-types/classDiagram/plugin.ts
@@ -19,9 +19,17 @@ interface RelationRecord {
target: string;
relation: ClassRelationToken;
label?: string;
+ sourceCardinality?: string;
+ targetCardinality?: string;
}
-const CLASS_ID_PATTERN = '[A-Za-z_][\\w.]*';
+const CLASS_ID_START_PATTERN = '[A-Za-z_]';
+const CLASS_ID_SEGMENT_PATTERN = '(?:[\\w.]|<[^>]+>|~[^~]+~|,)';
+const CLASS_ID_PATTERN = `${CLASS_ID_START_PATTERN}${CLASS_ID_SEGMENT_PATTERN}*`;
+
+function normalizeClassIdentifier(value: string): string {
+ return value.trim().replace(/~([^~]+)~/g, '<$1>');
+}
function createEmptyClass(id: string): ClassRecord {
return {
@@ -52,18 +60,33 @@ function parseClassBodyLine(line: string, record: ClassRecord): void {
function parseRelation(line: string): RelationRecord | null {
const relationTokenPattern = buildClassRelationTokenRegexPattern();
const relationMatch = line.match(
- new RegExp(`^(${CLASS_ID_PATTERN})\\s+(${relationTokenPattern})\\s+(${CLASS_ID_PATTERN})(?:\\s*:\\s*(.+))?$`)
+ new RegExp(
+ `^(${CLASS_ID_PATTERN})(?:\\s+"([^"]+)")?\\s+(${relationTokenPattern})\\s+(?:"([^"]+)"\\s+)?(${CLASS_ID_PATTERN})(?:\\s*:\\s*(.+))?$`
+ )
);
if (!relationMatch) return null;
return {
- source: relationMatch[1],
- relation: relationMatch[2] as ClassRelationToken,
- target: relationMatch[3],
- label: relationMatch[4]?.trim(),
+ source: normalizeClassIdentifier(relationMatch[1]),
+ sourceCardinality: relationMatch[2]?.trim(),
+ relation: relationMatch[3] as ClassRelationToken,
+ targetCardinality: relationMatch[4]?.trim(),
+ target: normalizeClassIdentifier(relationMatch[5]),
+ label: relationMatch[6]?.trim(),
};
}
+function ensureClassRecord(classes: Map, id: string): ClassRecord {
+ const existing = classes.get(id);
+ if (existing) {
+ return existing;
+ }
+
+ const created = createEmptyClass(id);
+ classes.set(id, created);
+ return created;
+}
+
function parseClassDiagram(input: string): { nodes: FlowNode[]; edges: FlowEdge[]; error?: string; diagnostics?: string[] } {
const lines = input.replace(/\r\n/g, '\n').split('\n');
const classes = new Map();
@@ -98,9 +121,8 @@ function parseClassDiagram(input: string): { nodes: FlowNode[]; edges: FlowEdge[
const inlineBlock = line.match(new RegExp(`^class\\s+(${CLASS_ID_PATTERN})\\s*\\{\\s*(.*?)\\s*\\}$`));
if (inlineBlock) {
- const id = inlineBlock[1];
- const existing = classes.get(id) || createEmptyClass(id);
- classes.set(id, existing);
+ const id = normalizeClassIdentifier(inlineBlock[1]);
+ const existing = ensureClassRecord(classes, id);
const members = inlineBlock[2]
.split(';')
.map((member) => member.trim())
@@ -111,9 +133,8 @@ function parseClassDiagram(input: string): { nodes: FlowNode[]; edges: FlowEdge[
const blockStart = line.match(new RegExp(`^class\\s+(${CLASS_ID_PATTERN})\\s*\\{\\s*$`));
if (blockStart) {
- const id = blockStart[1];
- const existing = classes.get(id) || createEmptyClass(id);
- classes.set(id, existing);
+ const id = normalizeClassIdentifier(blockStart[1]);
+ const existing = ensureClassRecord(classes, id);
activeClass = existing;
activeClassLine = lineNumber;
continue;
@@ -121,45 +142,37 @@ function parseClassDiagram(input: string): { nodes: FlowNode[]; edges: FlowEdge[
const classWithStereotype = line.match(new RegExp(`^class\\s+(${CLASS_ID_PATTERN})\\s*<<\\s*(.+?)\\s*>>\\s*$`));
if (classWithStereotype) {
- const id = classWithStereotype[1];
- const existing = classes.get(id) || createEmptyClass(id);
+ const id = normalizeClassIdentifier(classWithStereotype[1]);
+ const existing = ensureClassRecord(classes, id);
existing.stereotype = classWithStereotype[2];
- classes.set(id, existing);
continue;
}
const standaloneClass = line.match(new RegExp(`^class\\s+(${CLASS_ID_PATTERN})\\s*$`));
if (standaloneClass) {
- const id = standaloneClass[1];
- if (!classes.has(id)) {
- classes.set(id, createEmptyClass(id));
- }
+ const id = normalizeClassIdentifier(standaloneClass[1]);
+ ensureClassRecord(classes, id);
continue;
}
const classMemberInline = line.match(new RegExp(`^(${CLASS_ID_PATTERN})\\s*:\\s*(.+)$`));
if (classMemberInline) {
- const id = classMemberInline[1];
+ const id = normalizeClassIdentifier(classMemberInline[1]);
const member = classMemberInline[2].trim();
- const existing = classes.get(id) || createEmptyClass(id);
+ const existing = ensureClassRecord(classes, id);
if (/\(.*\)/.test(member)) {
existing.methods.push(member);
} else {
existing.attributes.push(member);
}
- classes.set(id, existing);
continue;
}
const relation = parseRelation(line);
if (relation) {
relations.push(relation);
- if (!classes.has(relation.source)) {
- classes.set(relation.source, createEmptyClass(relation.source));
- }
- if (!classes.has(relation.target)) {
- classes.set(relation.target, createEmptyClass(relation.target));
- }
+ ensureClassRecord(classes, relation.source);
+ ensureClassRecord(classes, relation.target);
continue;
}
@@ -212,15 +225,20 @@ function parseClassDiagram(input: string): { nodes: FlowNode[]; edges: FlowEdge[
}));
const edges: FlowEdge[] = relations.map((relation, index) => ({
- id: createId(`e-class-${index}`),
- source: relation.source,
- target: relation.target,
- label: relation.label || relation.relation,
- type: 'smoothstep',
- data: {
- classRelation: relation.relation,
- classRelationLabel: relation.label,
- },
+ id: createId(`e-class-${index}`),
+ source: relation.source,
+ target: relation.target,
+ label:
+ relation.label
+ || [relation.sourceCardinality, relation.targetCardinality].filter(Boolean).join(' ')
+ || relation.relation,
+ type: 'smoothstep',
+ data: {
+ classRelation: relation.relation,
+ classRelationLabel: relation.label,
+ classRelationSourceCardinality: relation.sourceCardinality,
+ classRelationTargetCardinality: relation.targetCardinality,
+ },
}));
return diagnostics.length > 0 ? { nodes, edges, diagnostics } : { nodes, edges };
diff --git a/src/diagram-types/core/contracts.ts b/src/diagram-types/core/contracts.ts
index 3be4a159..fb8e75b3 100644
--- a/src/diagram-types/core/contracts.ts
+++ b/src/diagram-types/core/contracts.ts
@@ -1,10 +1,13 @@
import type { DiagramType, FlowEdge, FlowNode } from '@/lib/types';
+import type { MermaidImportDiagnostic, MermaidImportStatus } from '@/services/mermaid/importContracts';
export interface DiagramParseResult {
nodes: FlowNode[];
edges: FlowEdge[];
error?: string;
diagnostics?: string[];
+ structuredDiagnostics?: MermaidImportDiagnostic[];
+ importState?: MermaidImportStatus;
}
export interface DiagramPlugin {
@@ -12,4 +15,3 @@ export interface DiagramPlugin {
displayName: string;
parseMermaid: (input: string) => DiagramParseResult;
}
-
diff --git a/src/diagram-types/erDiagram/plugin.test.ts b/src/diagram-types/erDiagram/plugin.test.ts
index 26a7f701..3c717dad 100644
--- a/src/diagram-types/erDiagram/plugin.test.ts
+++ b/src/diagram-types/erDiagram/plugin.test.ts
@@ -20,7 +20,15 @@ describe('ER_DIAGRAM_PLUGIN', () => {
expect(result.error).toBeUndefined();
expect(result.nodes).toHaveLength(2);
expect(result.edges).toHaveLength(1);
- expect(result.nodes.find((node) => node.id === 'CUSTOMER')?.data.erFields).toContain('string id PK');
+ expect(result.nodes.find((node) => node.id === 'CUSTOMER')?.data.erFields).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ name: 'id',
+ dataType: 'string',
+ isPrimaryKey: true,
+ }),
+ ])
+ );
});
it('returns error when header is missing', () => {
@@ -78,4 +86,81 @@ describe('ER_DIAGRAM_PLUGIN', () => {
expect(result.error).toBeUndefined();
expect(result.diagnostics?.some((message) => message.includes('Unclosed entity block started at line'))).toBe(true);
});
+
+ it('parses ER field uniqueness and references metadata', () => {
+ const input = `
+ erDiagram
+ ORDER {
+ uuid id PK
+ uuid customer_id FK REFERENCES CUSTOMER.id
+ string external_id UK
+ }
+ `;
+
+ const result = ER_DIAGRAM_PLUGIN.parseMermaid(input);
+ expect(result.error).toBeUndefined();
+ expect(result.nodes).toHaveLength(1);
+
+ const fields = result.nodes[0].data.erFields ?? [];
+ expect(fields).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ name: 'customer_id',
+ isForeignKey: true,
+ referencesTable: 'CUSTOMER',
+ referencesField: 'id',
+ }),
+ expect.objectContaining({
+ name: 'external_id',
+ isUnique: true,
+ }),
+ ])
+ );
+ });
+
+ it('parses table-only REFERENCES syntax used by Mermaid-compatible export', () => {
+ const input = `
+ erDiagram
+ ORDER {
+ uuid customer_id FK REFERENCES CUSTOMER
+ }
+ `;
+
+ const result = ER_DIAGRAM_PLUGIN.parseMermaid(input);
+ expect(result.error).toBeUndefined();
+ const fields = result.nodes[0].data.erFields ?? [];
+ expect(fields).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ name: 'customer_id',
+ isForeignKey: true,
+ referencesTable: 'CUSTOMER',
+ referencesField: undefined,
+ }),
+ ])
+ );
+ });
+
+ it('preserves dotted REFERENCES table paths and field names', () => {
+ const input = `
+ erDiagram
+ ORDER {
+ uuid customer_id FK REFERENCES billing.Customer.id
+ }
+ `;
+
+ const result = ER_DIAGRAM_PLUGIN.parseMermaid(input);
+ expect(result.error).toBeUndefined();
+ const fields = result.nodes[0].data.erFields ?? [];
+ expect(fields).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ name: 'customer_id',
+ isForeignKey: true,
+ referencesTable: 'billing.Customer',
+ referencesField: 'id',
+ }),
+ ])
+ );
+ });
});
diff --git a/src/diagram-types/erDiagram/plugin.ts b/src/diagram-types/erDiagram/plugin.ts
index 15a39199..737f6b7a 100644
--- a/src/diagram-types/erDiagram/plugin.ts
+++ b/src/diagram-types/erDiagram/plugin.ts
@@ -3,13 +3,15 @@ import {
buildERRelationTokenRegexPattern,
type ERRelationToken,
} from '@/lib/relationSemantics';
+import { createDefaultErField } from '@/lib/entityFields';
+import type { ErField } from '@/lib/types';
import type { FlowEdge, FlowNode } from '@/lib/types';
import type { DiagramPlugin } from '@/diagram-types/core';
interface EntityRecord {
id: string;
label: string;
- fields: string[];
+ fields: ErField[];
}
interface RelationRecord {
@@ -21,6 +23,26 @@ interface RelationRecord {
const ENTITY_ID_PATTERN = '[A-Za-z_][\\w.]*';
+function parseReferenceTarget(reference: string): {
+ referencesTable?: string;
+ referencesField?: string;
+} {
+ const trimmed = reference.trim();
+ if (!trimmed) {
+ return {};
+ }
+
+ const lastDotIndex = trimmed.lastIndexOf('.');
+ if (lastDotIndex <= 0 || lastDotIndex === trimmed.length - 1) {
+ return { referencesTable: trimmed };
+ }
+
+ return {
+ referencesTable: trimmed.slice(0, lastDotIndex),
+ referencesField: trimmed.slice(lastDotIndex + 1),
+ };
+}
+
function createEmptyEntity(id: string): EntityRecord {
return {
id,
@@ -29,6 +51,56 @@ function createEmptyEntity(id: string): EntityRecord {
};
}
+function parseMermaidErField(line: string): ErField {
+ const trimmed = line.trim();
+ if (!trimmed) {
+ return createDefaultErField();
+ }
+
+ const tokens = trimmed.split(/\s+/).filter(Boolean);
+ if (tokens.length < 2) {
+ return {
+ ...createDefaultErField(),
+ name: trimmed,
+ };
+ }
+
+ const [dataType, name, ...rawConstraints] = tokens;
+ const field: ErField = {
+ ...createDefaultErField(),
+ name,
+ dataType,
+ };
+
+ for (let index = 0; index < rawConstraints.length; index += 1) {
+ const token = rawConstraints[index].toUpperCase();
+ if (token === 'PK' || token === 'PRIMARY') {
+ field.isPrimaryKey = true;
+ continue;
+ }
+ if (token === 'FK' || token === 'FOREIGN') {
+ field.isForeignKey = true;
+ continue;
+ }
+ if (token === 'UK' || token === 'UNIQUE' || token === 'UQ') {
+ field.isUnique = true;
+ continue;
+ }
+ if (token === 'NN' || token === 'NOTNULL' || token === 'NOT') {
+ field.isNotNull = true;
+ continue;
+ }
+ if (token === 'REFERENCES' && rawConstraints[index + 1]) {
+ const reference = parseReferenceTarget(rawConstraints[index + 1]);
+ field.referencesTable = reference.referencesTable;
+ field.referencesField = reference.referencesField;
+ index += 1;
+ }
+ }
+
+ return field;
+}
+
function parseRelation(line: string): RelationRecord | null {
const relationTokenPattern = buildERRelationTokenRegexPattern();
const match = line.match(
@@ -77,7 +149,7 @@ function parseERDiagram(input: string): { nodes: FlowNode[]; edges: FlowEdge[];
activeEntityLine = -1;
continue;
}
- activeEntity.fields.push(line);
+ activeEntity.fields.push(parseMermaidErField(line));
continue;
}
diff --git a/src/diagram-types/journey/fuzzCorpus.test.ts b/src/diagram-types/journey/fuzzCorpus.test.ts
index 3db571e1..354b034f 100644
--- a/src/diagram-types/journey/fuzzCorpus.test.ts
+++ b/src/diagram-types/journey/fuzzCorpus.test.ts
@@ -31,7 +31,7 @@ const FUZZ_CASES: FuzzCase[] = [
`,
expectDiagnosticsIncludes: [
'Invalid journey section syntax at line',
- 'Invalid journey step syntax at line',
+ 'Invalid journey score at line',
],
},
{
diff --git a/src/diagram-types/journey/plugin.test.ts b/src/diagram-types/journey/plugin.test.ts
index a256d0a2..d011bd66 100644
--- a/src/diagram-types/journey/plugin.test.ts
+++ b/src/diagram-types/journey/plugin.test.ts
@@ -18,12 +18,28 @@ describe('JOURNEY_PLUGIN', () => {
expect(result.nodes).toHaveLength(3);
expect(result.edges).toHaveLength(1);
expect(result.nodes[0].type).toBe('journey');
+ expect(result.nodes[0].data.journeyTitle).toBe('Checkout Journey');
expect(result.nodes[0].data.journeySection).toBe('Happy Path');
expect(result.nodes[0].data.journeyScore).toBe(5);
expect(result.nodes[0].data.journeyActor).toBe('Buyer');
});
- it('returns diagnostics for malformed score while keeping valid parse', () => {
+ it('emits diagnostics for malformed title syntax while preserving valid steps', () => {
+ const input = `
+ journey
+ title
+ section Support
+ Open ticket: 3: User
+ `;
+
+ const result = JOURNEY_PLUGIN.parseMermaid(input);
+ expect(result.error).toBeUndefined();
+ expect(result.nodes).toHaveLength(1);
+ expect(result.nodes[0].data.journeyTitle).toBe('Journey');
+ expect(result.diagnostics?.some((message) => message.includes('Invalid journey title syntax at line'))).toBe(true);
+ });
+
+ it('returns diagnostics for malformed score and skips invalid steps', () => {
const input = `
journey
section Support
@@ -33,11 +49,42 @@ describe('JOURNEY_PLUGIN', () => {
const result = JOURNEY_PLUGIN.parseMermaid(input);
expect(result.error).toBeUndefined();
- expect(result.nodes).toHaveLength(2);
+ expect(result.nodes).toHaveLength(1);
expect(result.diagnostics?.some((message) => message.includes('Invalid journey score at line'))).toBe(true);
});
- it('returns diagnostics for malformed section and invalid step syntax while preserving valid steps', () => {
+ it('color-codes steps by score for quick satisfaction scanning', () => {
+ const input = `
+ journey
+ section Checkout
+ Browse: 5: Buyer
+ Retry payment: 1: Buyer
+ `;
+
+ const result = JOURNEY_PLUGIN.parseMermaid(input);
+ expect(result.error).toBeUndefined();
+ expect(result.nodes[0].data.color).toBe('emerald');
+ expect(result.nodes[1].data.color).toBe('red');
+ });
+
+ it('preserves journey tasks and actors that contain colons', () => {
+ const input = `
+ journey
+ section Incident Flow
+ HTTP: 500 Error: 1: SRE: On-call
+ Recover service: 4: API: Team
+ `;
+
+ const result = JOURNEY_PLUGIN.parseMermaid(input);
+ expect(result.error).toBeUndefined();
+ expect(result.nodes).toHaveLength(2);
+ expect(result.nodes[0].data.journeyTask).toBe('HTTP: 500 Error');
+ expect(result.nodes[0].data.journeyScore).toBe(1);
+ expect(result.nodes[0].data.journeyActor).toBe('SRE: On-call');
+ expect(result.nodes[1].data.journeyActor).toBe('API: Team');
+ });
+
+ it('returns diagnostics for malformed section and malformed score-like steps while preserving valid steps', () => {
const input = `
journey
section
@@ -49,7 +96,7 @@ describe('JOURNEY_PLUGIN', () => {
expect(result.error).toBeUndefined();
expect(result.nodes).toHaveLength(1);
expect(result.diagnostics?.some((message) => message.includes('Invalid journey section syntax at line'))).toBe(true);
- expect(result.diagnostics?.some((message) => message.includes('Invalid journey step syntax at line'))).toBe(true);
+ expect(result.diagnostics?.some((message) => message.includes('Invalid journey score at line'))).toBe(true);
});
it('returns error when header is missing', () => {
diff --git a/src/diagram-types/journey/plugin.ts b/src/diagram-types/journey/plugin.ts
index d6354084..ff02b742 100644
--- a/src/diagram-types/journey/plugin.ts
+++ b/src/diagram-types/journey/plugin.ts
@@ -21,6 +21,14 @@ function normalizeScore(input: string): number | null {
return rounded;
}
+function getJourneyScoreColor(score: number | undefined): string {
+ if (typeof score !== 'number') return 'slate';
+ if (score >= 4) return 'emerald';
+ if (score === 3) return 'amber';
+ if (score === 2) return 'orange';
+ return 'red';
+}
+
interface ParsedJourneyStep {
task: string;
actor?: string;
@@ -28,39 +36,57 @@ interface ParsedJourneyStep {
scoreMalformed: boolean;
}
+function buildJourneyStep(
+ task: string,
+ scoreMalformed: boolean,
+ score?: number,
+ actor?: string
+): ParsedJourneyStep | null {
+ const normalizedTask = task.trim();
+ if (!normalizedTask) {
+ return null;
+ }
+ const normalizedActor = actor?.trim() || undefined;
+
+ return {
+ task: normalizedTask,
+ actor: normalizedActor,
+ score,
+ scoreMalformed,
+ };
+}
+
+function joinJourneySegments(parts: string[]): string {
+ return parts.join(': ');
+}
+
function parseJourneyStep(line: string): ParsedJourneyStep | null {
const parts = line.split(':').map((item) => item.trim());
if (parts.length === 0) return null;
- const task = parts[0];
- if (!task) return null;
-
if (parts.length === 1) {
- return { task, scoreMalformed: false };
+ return buildJourneyStep(parts[0], false);
}
- if (parts.length === 2) {
- const score = normalizeScore(parts[1]);
+ for (let scoreIndex = parts.length - 1; scoreIndex >= 1; scoreIndex -= 1) {
+ const score = normalizeScore(parts[scoreIndex]);
if (score === null) {
- return null;
+ continue;
}
- return { task, score, scoreMalformed: false };
+ return buildJourneyStep(
+ joinJourneySegments(parts.slice(0, scoreIndex)),
+ false,
+ score,
+ joinJourneySegments(parts.slice(scoreIndex + 1))
+ );
}
- const score = normalizeScore(parts[1]);
- if (score === null) {
- return {
- task,
- actor: parts.slice(2).join(':').trim() || undefined,
- scoreMalformed: true,
- };
- }
- return {
- task,
- score,
- actor: parts.slice(2).join(':').trim() || undefined,
- scoreMalformed: false,
- };
+ return buildJourneyStep(
+ parts[0],
+ true,
+ undefined,
+ parts.length > 2 ? joinJourneySegments(parts.slice(2)) : undefined
+ );
}
function parseJourney(input: string): { nodes: FlowNode[]; edges: FlowEdge[]; error?: string; diagnostics?: string[] } {
@@ -70,6 +96,7 @@ function parseJourney(input: string): { nodes: FlowNode[]; edges: FlowEdge[]; er
let hasHeader = false;
let currentSection = 'General';
+ let journeyTitle = 'Journey';
for (const [index, rawLine] of lines.entries()) {
const lineNumber = index + 1;
@@ -82,7 +109,13 @@ function parseJourney(input: string): { nodes: FlowNode[]; edges: FlowEdge[]; er
}
if (!hasHeader) continue;
- if (/^title\s+/i.test(line)) {
+ if (/^title\b/i.test(line)) {
+ const titleMatch = line.match(/^title\s+(.+)$/i);
+ if (titleMatch?.[1]?.trim()) {
+ journeyTitle = titleMatch[1].trim();
+ } else {
+ diagnostics.push(`Invalid journey title syntax at line ${lineNumber}: "${line}"`);
+ }
continue;
}
@@ -109,6 +142,7 @@ function parseJourney(input: string): { nodes: FlowNode[]; edges: FlowEdge[]; er
}
if (parsedStep.scoreMalformed) {
diagnostics.push(`Invalid journey score at line ${lineNumber}: "${line}" (expected 0-5)`);
+ continue;
}
steps.push({
@@ -159,8 +193,9 @@ function parseJourney(input: string): { nodes: FlowNode[]; edges: FlowEdge[]; er
data: {
label: step.task,
subLabel: step.actor,
- color: 'violet',
+ color: getJourneyScoreColor(step.score),
shape: 'rounded',
+ journeyTitle,
journeySection: step.section,
journeyTask: step.task,
journeyActor: step.actor,
diff --git a/src/diagram-types/mindmap/plugin.test.ts b/src/diagram-types/mindmap/plugin.test.ts
index 982ba59f..52146cea 100644
--- a/src/diagram-types/mindmap/plugin.test.ts
+++ b/src/diagram-types/mindmap/plugin.test.ts
@@ -19,6 +19,7 @@ describe('MINDMAP_PLUGIN', () => {
expect(result.nodes).toHaveLength(6);
expect(result.edges).toHaveLength(5);
expect(result.nodes[0].data.label).toBe('Roadmap');
+ expect(result.nodes[0].data.mindmapWrapper).toBe('double-circle');
expect(result.nodes[0].type).toBe('mindmap');
expect(result.nodes.some((node) => node.data.label === 'Mermaid')).toBe(true);
const productNode = result.nodes.find((node) => node.data.label === 'Product');
@@ -105,4 +106,57 @@ describe('MINDMAP_PLUGIN', () => {
expect(result.error).toBeUndefined();
expect(result.diagnostics?.some((message) => message.includes('Malformed mindmap wrapper syntax at line'))).toBe(true);
});
+
+ it('preserves wrapper metadata for supported mindmap shapes', () => {
+ const input = `
+ mindmap
+ ((Root))
+ [[Topic]]
+ ([Action])
+ [(Service)]
+ [Plain]
+ (Rounded)
+ {{Decision}}
+ `;
+
+ const result = MINDMAP_PLUGIN.parseMermaid(input);
+ expect(result.error).toBeUndefined();
+ expect(result.nodes.find((node) => node.data.label === 'Root')?.data.mindmapWrapper).toBe('double-circle');
+ expect(result.nodes.find((node) => node.data.label === 'Topic')?.data.mindmapWrapper).toBe('double-square');
+ expect(result.nodes.find((node) => node.data.label === 'Action')?.data.mindmapWrapper).toBe('stadium');
+ expect(result.nodes.find((node) => node.data.label === 'Service')?.data.mindmapWrapper).toBe('subroutine');
+ expect(result.nodes.find((node) => node.data.label === 'Plain')?.data.mindmapWrapper).toBe('square');
+ expect(result.nodes.find((node) => node.data.label === 'Rounded')?.data.mindmapWrapper).toBe('rounded');
+ expect(result.nodes.find((node) => node.data.label === 'Decision')?.data.mindmapWrapper).toBe('hexagon');
+ });
+
+ it('preserves Mermaid alias prefixes for wrapped nodes', () => {
+ const input = `
+ mindmap
+ root((Root))
+ feature[[Topic]]
+ branch(Child)
+ `;
+
+ const result = MINDMAP_PLUGIN.parseMermaid(input);
+ expect(result.error).toBeUndefined();
+ expect(result.nodes.find((node) => node.data.label === 'Root')?.data.mindmapAlias).toBe('root');
+ expect(result.nodes.find((node) => node.data.label === 'Topic')?.data.mindmapAlias).toBe('feature');
+ expect(result.nodes.find((node) => node.data.label === 'Child')?.data.mindmapAlias).toBe('branch');
+ });
+
+ it('preserves dotted Mermaid alias prefixes for wrapped nodes', () => {
+ const input = `
+ mindmap
+ platform.root((Root))
+ platform.api[[Topic]]
+ platform.branch(Child)
+ `;
+
+ const result = MINDMAP_PLUGIN.parseMermaid(input);
+ expect(result.error).toBeUndefined();
+ expect(result.nodes.find((node) => node.data.label === 'Root')?.data.mindmapAlias).toBe('platform.root');
+ expect(result.nodes.find((node) => node.data.label === 'Topic')?.data.mindmapAlias).toBe('platform.api');
+ expect(result.nodes.find((node) => node.data.label === 'Child')?.data.mindmapAlias).toBe('platform.branch');
+ });
});
diff --git a/src/diagram-types/mindmap/plugin.ts b/src/diagram-types/mindmap/plugin.ts
index 6edf29bb..1b3406a5 100644
--- a/src/diagram-types/mindmap/plugin.ts
+++ b/src/diagram-types/mindmap/plugin.ts
@@ -5,6 +5,8 @@ interface ParsedMindmapNode {
depth: number;
label: string;
lineNumber: number;
+ alias?: string;
+ wrapper?: NonNullable;
}
interface StructuredNode extends ParsedMindmapNode {
@@ -15,8 +17,17 @@ interface StructuredNode extends ParsedMindmapNode {
const X_GAP = 260;
const Y_GAP = 110;
const ROOT_GAP = 80;
+const MINDMAP_ALIAS_PATTERN = '[A-Za-z_][\\w.-]*';
+
+function createWrappedMindmapPattern(open: string, close: string): RegExp {
+ return new RegExp(`^(${MINDMAP_ALIAS_PATTERN})?\\s*${open}(.+)${close}$`);
+}
function getIndentDepth(rawLine: string): number {
+ return Math.floor(getLeadingIndentUnits(rawLine) / 2);
+}
+
+function getLeadingIndentUnits(rawLine: string): number {
let indentUnits = 0;
for (const char of rawLine) {
if (char === ' ') {
@@ -29,10 +40,16 @@ function getIndentDepth(rawLine: string): number {
}
break;
}
- return Math.floor(indentUnits / 2);
+ return indentUnits;
}
-function extractMindmapLabel(rawContent: string): string | null {
+function extractMindmapLabel(
+ rawContent: string
+): {
+ label: string;
+ alias?: string;
+ wrapper?: NonNullable;
+} | null {
const trimmed = rawContent.trim();
if (!trimmed) return null;
if (trimmed.startsWith('%%')) return null;
@@ -41,20 +58,34 @@ function extractMindmapLabel(rawContent: string): string | null {
const withoutDirective = trimmed.replace(/\s*::.+$/, '').trim();
if (!withoutDirective) return null;
- const wrappedMatch = withoutDirective.match(
- /^(?:[A-Za-z_][\w-]*\s*)?(?:\(\((.+)\)\)|\[\[(.+)\]\]|\(\[(.+)\]\)|\[\((.+)\)\]|\[(.+)\]|\((.+)\)|\{\{(.+)\}\))$/
- );
- if (wrappedMatch) {
- const value = wrappedMatch.slice(1).find((candidate) => typeof candidate === 'string' && candidate.trim().length > 0);
- if (value) return value.trim();
+ const wrapperDefinitions: Array<{
+ wrapper: NonNullable;
+ pattern: RegExp;
+ }> = [
+ { wrapper: 'double-circle', pattern: createWrappedMindmapPattern('\\(\\(', '\\)\\)') },
+ { wrapper: 'double-square', pattern: createWrappedMindmapPattern('\\[\\[', '\\]\\]') },
+ { wrapper: 'stadium', pattern: createWrappedMindmapPattern('\\(\\[', '\\]\\)') },
+ { wrapper: 'subroutine', pattern: createWrappedMindmapPattern('\\[\\(', '\\)\\]') },
+ { wrapper: 'square', pattern: createWrappedMindmapPattern('\\[', '\\]') },
+ { wrapper: 'rounded', pattern: createWrappedMindmapPattern('\\(', '\\)') },
+ { wrapper: 'hexagon', pattern: createWrappedMindmapPattern('\\{\\{', '\\}\\}') },
+ ];
+
+ for (const definition of wrapperDefinitions) {
+ const wrappedMatch = withoutDirective.match(definition.pattern);
+ const alias = wrappedMatch?.[1]?.trim();
+ const value = wrappedMatch?.[2]?.trim();
+ if (value) {
+ return { label: value, alias, wrapper: definition.wrapper };
+ }
}
- return withoutDirective;
+ return { label: withoutDirective };
}
function hasMalformedWrappedLabel(rawContent: string): boolean {
const trimmed = rawContent.trim();
- const compact = trimmed.replace(/^[A-Za-z_][\w-]*\s*/, '');
+ const compact = trimmed.replace(new RegExp(`^${MINDMAP_ALIAS_PATTERN}\\s*`), '');
const wrappers: Array<{ open: string; close: string }> = [
{ open: '((', close: '))' },
{ open: '[[', close: ']]' },
@@ -93,16 +124,10 @@ function parseMindmap(input: string): { nodes: FlowNode[]; edges: FlowEdge[]; er
continue;
}
- const label = extractMindmapLabel(rawLine);
- if (!label) continue;
+ const parsedLabel = extractMindmapLabel(rawLine);
+ if (!parsedLabel) continue;
- const indentUnits = rawLine
- .split('')
- .reduce((sum, char) => {
- if (char === ' ') return sum + 1;
- if (char === '\t') return sum + 2;
- return sum;
- }, 0);
+ const indentUnits = getLeadingIndentUnits(rawLine);
if (indentUnits % 2 !== 0) {
diagnostics.push(`Odd indentation width at line ${lineNumber}; mindmap expects 2-space indentation steps.`);
}
@@ -118,8 +143,10 @@ function parseMindmap(input: string): { nodes: FlowNode[]; edges: FlowEdge[]; er
parsedNodes.push({
depth,
- label,
+ label: parsedLabel.label,
+ alias: parsedLabel.alias,
lineNumber,
+ wrapper: parsedLabel.wrapper,
});
}
@@ -158,7 +185,9 @@ function parseMindmap(input: string): { nodes: FlowNode[]; edges: FlowEdge[]; er
id: `mm-${index + 1}`,
depth: parsedNode.depth,
label: parsedNode.label,
+ alias: parsedNode.alias,
lineNumber: parsedNode.lineNumber,
+ wrapper: parsedNode.wrapper,
parentIndex,
});
stack.push({ depth: parsedNode.depth, index });
@@ -231,6 +260,8 @@ function parseMindmap(input: string): { nodes: FlowNode[]; edges: FlowEdge[]; er
shape: node.depth === 0 ? 'rounded' : 'rectangle',
mindmapDepth: node.depth,
mindmapParentId: node.parentIndex === null ? undefined : structuredNodes[node.parentIndex].id,
+ mindmapAlias: node.alias,
+ mindmapWrapper: node.wrapper,
},
}));
diff --git a/src/diagram-types/sequence/plugin.test.ts b/src/diagram-types/sequence/plugin.test.ts
index 3e1e97e0..bf1bc8b2 100644
--- a/src/diagram-types/sequence/plugin.test.ts
+++ b/src/diagram-types/sequence/plugin.test.ts
@@ -98,7 +98,8 @@ describe('SEQUENCE_PLUGIN', () => {
end`;
const result = SEQUENCE_PLUGIN.parseMermaid(input);
- expect(result.nodes).toHaveLength(2);
+ expect(result.nodes).toHaveLength(3);
+ expect(result.nodes.some((node) => node.type === 'annotation')).toBe(true);
expect(result.edges).toHaveLength(1);
});
@@ -109,4 +110,102 @@ describe('SEQUENCE_PLUGIN', () => {
const result = SEQUENCE_PLUGIN.parseMermaid(input);
expect(result.edges[0].data?.seqMessageKind).toBe('async');
});
+
+ it('creates visible note and fragment nodes for sequence annotations', () => {
+ const input = `sequenceDiagram
+ participant A
+ participant B
+ note right of A: warm cache
+ alt success
+ A->>B: Request
+ else failure
+ B-->>A: Error
+ end`;
+
+ const result = SEQUENCE_PLUGIN.parseMermaid(input);
+ expect(result.error).toBeUndefined();
+ expect(result.nodes.some((node) => node.type === 'sequence_note')).toBe(true);
+ expect(result.nodes.some((node) => node.type === 'annotation')).toBe(true);
+ expect(result.edges[0].data?.seqFragment?.type).toBe('alt');
+ });
+
+ it('preserves note-over two participants as shared note metadata', () => {
+ const input = `sequenceDiagram
+ participant A
+ participant B
+ note over A, B: Shared context
+ A->>B: Hello`;
+
+ const result = SEQUENCE_PLUGIN.parseMermaid(input);
+ const noteNode = result.nodes.find((node) => node.type === 'sequence_note');
+
+ expect(noteNode).toBeDefined();
+ expect(noteNode?.data.seqNoteTarget).toBe('A');
+ expect(noteNode?.data.seqNoteTargets).toEqual(['A', 'B']);
+ expect(noteNode?.data.label).toBe('Shared context');
+ });
+
+ it('preserves explicit activation and deactivation events with message order metadata', () => {
+ const input = `sequenceDiagram
+ participant A
+ participant B
+ A->>B: Request
+ activate B
+ B-->>A: Response
+ deactivate B`;
+
+ const result = SEQUENCE_PLUGIN.parseMermaid(input);
+ const participant = result.nodes.find((node) => node.id === 'B');
+
+ expect(participant?.data.seqActivations).toEqual([
+ { order: 1, activate: true },
+ { order: 2, activate: false },
+ ]);
+ });
+
+ it('preserves fragment metadata on notes inside sequence control blocks', () => {
+ const input = `sequenceDiagram
+ participant A
+ participant B
+ alt success
+ note over A, B: Shared context
+ A->>B: Request
+ else failure
+ B-->>A: Error
+ end`;
+
+ const result = SEQUENCE_PLUGIN.parseMermaid(input);
+ const noteNode = result.nodes.find((node) => node.type === 'sequence_note');
+
+ expect(noteNode?.data.seqFragment).toMatchObject({
+ type: 'alt',
+ condition: 'success',
+ branchKind: 'start',
+ });
+ });
+
+ it('preserves critical and option branch metadata on sequence messages', () => {
+ const input = `sequenceDiagram
+ participant A
+ participant B
+ critical primary path
+ A->>B: Request
+ option fallback path
+ B-->>A: Error
+ end`;
+
+ const result = SEQUENCE_PLUGIN.parseMermaid(input);
+
+ expect(result.error).toBeUndefined();
+ expect(result.edges[0].data?.seqFragment).toMatchObject({
+ type: 'critical',
+ condition: 'primary path',
+ branchKind: 'start',
+ });
+ expect(result.edges[1].data?.seqFragment).toMatchObject({
+ type: 'critical',
+ condition: 'fallback path',
+ branchKind: 'option',
+ });
+ });
});
diff --git a/src/diagram-types/sequence/plugin.ts b/src/diagram-types/sequence/plugin.ts
index cfb03f17..7bd215d7 100644
--- a/src/diagram-types/sequence/plugin.ts
+++ b/src/diagram-types/sequence/plugin.ts
@@ -16,10 +16,12 @@ interface ParsedMessage {
}
interface ParsedFragment {
+ id: string;
type: 'alt' | 'loop' | 'opt' | 'par' | 'break' | 'critical';
condition: string;
+ branchKind: 'start' | 'else' | 'and' | 'option';
startOrder: number;
- elseOrder?: number;
+ endOrder: number;
}
interface ParsedActivation {
@@ -31,8 +33,32 @@ interface ParsedActivation {
interface ParsedNote {
text: string;
target: string;
+ targetIds: string[];
position: 'over' | 'left' | 'right';
order: number;
+ fragment?: {
+ type: ParsedFragment['type'];
+ condition: string;
+ branchKind: ParsedFragment['branchKind'];
+ };
+}
+
+function getSequenceFragmentColor(fragmentType: ParsedFragment['type']): string {
+ switch (fragmentType) {
+ case 'loop':
+ return 'blue';
+ case 'opt':
+ return 'amber';
+ case 'critical':
+ return 'red';
+ default:
+ return 'violet';
+ }
+}
+
+function getParticipantLaneIndex(participants: ParsedParticipant[], participantId: string): number {
+ const laneIndex = participants.findIndex((participant) => participant.id === participantId);
+ return Math.max(0, laneIndex);
}
function resolveMessageKind(arrow: string): ParsedMessage['kind'] {
@@ -59,11 +85,57 @@ function parseSequence(input: string): {
let hasHeader = false;
let messageOrder = 0;
const fragmentStack: Array<{
+ id: string;
type: ParsedFragment['type'];
condition: string;
+ branchKind: ParsedFragment['branchKind'];
startOrder: number;
}> = [];
+ function pushCompletedFragmentBranch(
+ fragment: {
+ id: string;
+ type: ParsedFragment['type'];
+ condition: string;
+ branchKind: ParsedFragment['branchKind'];
+ startOrder: number;
+ }
+ ): void {
+ fragments.push({
+ id: `${fragment.id}-branch-${fragments.length + 1}`,
+ type: fragment.type,
+ condition: fragment.condition,
+ branchKind: fragment.branchKind,
+ startOrder: fragment.startOrder,
+ endOrder: messageOrder,
+ });
+ }
+
+ function switchFragmentBranch(
+ line: string,
+ params: {
+ keyword: 'else' | 'and' | 'option';
+ allowedType: 'alt' | 'par' | 'critical';
+ branchKind: 'else' | 'and' | 'option';
+ }
+ ): boolean {
+ const match = line.match(new RegExp(`^${params.keyword}\\s+(.+)$`, 'i'));
+ if (!match || fragmentStack.length === 0) {
+ return false;
+ }
+
+ const top = fragmentStack[fragmentStack.length - 1];
+ if (top.type !== params.allowedType) {
+ return true;
+ }
+
+ pushCompletedFragmentBranch(top);
+ top.condition = match[1].trim();
+ top.branchKind = params.branchKind;
+ top.startOrder = messageOrder;
+ return true;
+ }
+
function ensureParticipant(name: string): void {
if (knownIds.has(name)) return;
participants.push({ id: name, label: name, kind: 'participant' });
@@ -108,33 +180,49 @@ function parseSequence(input: string): {
const fragmentMatch = line.match(/^(alt|loop|opt|par|break|critical)\s+(.+)$/i);
if (fragmentMatch) {
fragmentStack.push({
+ id: `seq-fragment-${fragmentStack.length + fragments.length + 1}`,
type: fragmentMatch[1].toLowerCase() as ParsedFragment['type'],
condition: fragmentMatch[2].trim(),
+ branchKind: 'start',
startOrder: messageOrder,
});
continue;
}
- const elseMatch = line.match(/^else\s+(.+)$/i);
- if (elseMatch && fragmentStack.length > 0) {
- const top = fragmentStack[fragmentStack.length - 1];
- if (top.type === 'alt') {
- fragments.push({
- type: top.type,
- condition: top.condition,
- startOrder: top.startOrder,
- elseOrder: messageOrder,
- });
- top.condition = elseMatch[1].trim();
- top.startOrder = messageOrder;
- }
+ if (
+ switchFragmentBranch(line, {
+ keyword: 'else',
+ allowedType: 'alt',
+ branchKind: 'else',
+ })
+ ) {
+ continue;
+ }
+
+ if (
+ switchFragmentBranch(line, {
+ keyword: 'and',
+ allowedType: 'par',
+ branchKind: 'and',
+ })
+ ) {
+ continue;
+ }
+
+ if (
+ switchFragmentBranch(line, {
+ keyword: 'option',
+ allowedType: 'critical',
+ branchKind: 'option',
+ })
+ ) {
continue;
}
if (/^end\b/i.test(line)) {
if (fragmentStack.length > 0) {
const top = fragmentStack.pop()!;
- fragments.push({ type: top.type, condition: top.condition, startOrder: top.startOrder });
+ pushCompletedFragmentBranch(top);
}
continue;
}
@@ -149,7 +237,20 @@ function parseSequence(input: string): {
const text = noteMatch[4].trim();
ensureParticipant(target1);
if (target2) ensureParticipant(target2);
- notes.push({ text, target: target1, position, order: messageOrder });
+ notes.push({
+ text,
+ target: target1,
+ targetIds: target2 ? [target1, target2] : [target1],
+ position,
+ order: messageOrder,
+ fragment: fragmentStack.length > 0
+ ? {
+ type: fragmentStack[fragmentStack.length - 1].type,
+ condition: fragmentStack[fragmentStack.length - 1].condition,
+ branchKind: fragmentStack[fragmentStack.length - 1].branchKind,
+ }
+ : undefined,
+ });
continue;
}
@@ -197,11 +298,14 @@ function parseSequence(input: string): {
const LANE_WIDTH = 220;
const participantKindMap = new Map(participants.map((p) => [p.id, p.kind]));
- const activationByParticipant = new Map();
+ const activationByParticipant = new Map>();
for (const act of activations) {
if (!activationByParticipant.has(act.participant))
activationByParticipant.set(act.participant, []);
- activationByParticipant.get(act.participant)!.push(act.order);
+ activationByParticipant.get(act.participant)!.push({
+ order: act.order,
+ activate: act.activate,
+ });
}
const nodes: FlowNode[] = participants.map((p, i) => ({
@@ -217,10 +321,7 @@ function parseSequence(input: string): {
}));
const edges: FlowEdge[] = messages.map((msg, i) => {
- const frag = fragments.find((f) => {
- const end = f.elseOrder !== undefined ? f.elseOrder : f.startOrder;
- return i >= f.startOrder && i <= end;
- });
+ const frag = [...fragments].reverse().find((f) => i >= f.startOrder && i <= f.endOrder);
return {
id: `e-seq-${i + 1}`,
@@ -235,7 +336,14 @@ function parseSequence(input: string): {
seqMessageOrder: i,
sourceIsActor: participantKindMap.get(msg.from) === 'actor',
targetIsActor: participantKindMap.get(msg.to) === 'actor',
- seqFragment: frag ? { type: frag.type, condition: frag.condition, edgeIds: [] } : undefined,
+ seqFragment: frag
+ ? {
+ type: frag.type,
+ condition: frag.condition,
+ branchKind: frag.branchKind,
+ edgeIds: [],
+ }
+ : undefined,
},
};
});
@@ -243,17 +351,46 @@ function parseSequence(input: string): {
const noteNodes: FlowNode[] = notes.map((note, i) => ({
id: `seq-note-${i + 1}`,
type: 'sequence_note',
- position: { x: 0, y: 0 },
+ position: {
+ x: getParticipantLaneIndex(participants, note.target) * LANE_WIDTH
+ + (note.position === 'left' ? -160 : note.position === 'right' ? 160 : 0),
+ y: 110 + note.order * 110,
+ },
data: {
label: note.text,
seqNoteTarget: note.target,
+ seqNoteTargets: note.targetIds,
seqNotePosition: note.position,
seqMessageOrder: note.order,
+ seqFragment: note.fragment
+ ? {
+ type: note.fragment.type,
+ condition: note.fragment.condition,
+ branchKind: note.fragment.branchKind,
+ edgeIds: [],
+ }
+ : undefined,
+ },
+ }));
+
+ const fragmentNodes: FlowNode[] = fragments.map((fragment, index) => ({
+ id: fragment.id,
+ type: 'annotation',
+ position: {
+ x: -260,
+ y: 80 + fragment.startOrder * 110 + index * 12,
+ },
+ data: {
+ label: fragment.type.toUpperCase(),
+ subLabel: fragment.condition,
+ color: getSequenceFragmentColor(fragment.type),
+ seqFragmentId: fragment.id,
+ seqMessageOrder: fragment.startOrder,
},
}));
return {
- nodes: [...nodes, ...noteNodes],
+ nodes: [...nodes, ...noteNodes, ...fragmentNodes],
edges,
...(diagnostics.length > 0 ? { diagnostics } : {}),
};
diff --git a/src/diagram-types/stateDiagram/plugin.test.ts b/src/diagram-types/stateDiagram/plugin.test.ts
index 553c21f6..5424b803 100644
--- a/src/diagram-types/stateDiagram/plugin.test.ts
+++ b/src/diagram-types/stateDiagram/plugin.test.ts
@@ -51,19 +51,83 @@ describe('STATE_DIAGRAM_PLUGIN', () => {
expect(busyNode?.parentId).toBe('Working');
});
- it('returns deterministic diagnostics for unsupported note, invalid direction, and malformed transition arrows', () => {
+ it('keeps standalone state declarations parented inside composite blocks', () => {
+ const input = `
+ stateDiagram-v2
+ state Working {
+ state Busy
+ state Idle
+ Busy --> Idle
+ }
+ Idle --> [*]
+ `;
+
+ const result = STATE_DIAGRAM_PLUGIN.parseMermaid(input);
+ expect(result.error).toBeUndefined();
+ expect(result.nodes.find((node) => node.id === 'Busy')?.parentId).toBe('Working');
+ expect(result.nodes.find((node) => node.id === 'Idle')?.parentId).toBe('Working');
+ });
+
+ it('renders state notes as annotation nodes instead of rejecting them', () => {
+ const input = `
+ stateDiagram-v2
+ [*] --> Idle
+ note right of Idle: Waiting for input
+ Idle --> Running
+ `;
+
+ const result = STATE_DIAGRAM_PLUGIN.parseMermaid(input);
+ expect(result.error).toBeUndefined();
+ expect(result.nodes.some((node) => node.type === 'annotation')).toBe(true);
+ expect(result.edges.some((edge) => edge.source.startsWith('state-note-') && edge.target === 'Idle')).toBe(true);
+ expect(result.diagnostics?.some((message) => message.includes('note syntax'))).not.toBe(true);
+ });
+
+ it('accepts quoted note targets for aliased composite states', () => {
+ const input = `
+ stateDiagram-v2
+ state "Working Set" as WorkingSet {
+ [*] --> Busy
+ }
+ note left of "WorkingSet": Parent note
+ `;
+
+ const result = STATE_DIAGRAM_PLUGIN.parseMermaid(input);
+ expect(result.error).toBeUndefined();
+ expect(result.nodes.some((node) => node.data.stateNoteTarget === 'WorkingSet')).toBe(true);
+ expect(result.diagnostics?.some((message) => message.includes('note syntax'))).not.toBe(true);
+ });
+
+ it('parses explicit fork and join control states', () => {
+ const input = `
+ stateDiagram-v2
+ state FanOut <>
+ state FanIn <>
+ [*] --> FanOut
+ FanOut --> BranchA
+ FanOut --> BranchB
+ BranchA --> FanIn
+ BranchB --> FanIn
+ FanIn --> [*]
+ `;
+
+ const result = STATE_DIAGRAM_PLUGIN.parseMermaid(input);
+ expect(result.error).toBeUndefined();
+ expect(result.nodes.find((node) => node.id === 'FanOut')?.data.stateControlKind).toBe('fork');
+ expect(result.nodes.find((node) => node.id === 'FanIn')?.data.stateControlKind).toBe('join');
+ });
+
+ it('returns deterministic diagnostics for invalid direction and malformed transition arrows', () => {
const input = `
stateDiagram-v2
[*] --> Idle
direction RL
- note right of Idle: unsupported
Idle -> Running
`;
const result = STATE_DIAGRAM_PLUGIN.parseMermaid(input);
expect(result.error).toBeUndefined();
expect(result.diagnostics?.some((message) => message.includes('Invalid stateDiagram direction syntax at line'))).toBe(true);
- expect(result.diagnostics?.some((message) => message.includes('Unsupported stateDiagram note syntax at line'))).toBe(true);
expect(result.diagnostics?.some((message) => message.includes('Invalid stateDiagram transition syntax at line'))).toBe(true);
});
});
diff --git a/src/diagram-types/stateDiagram/plugin.ts b/src/diagram-types/stateDiagram/plugin.ts
index e9cdb06c..e1fb48f2 100644
--- a/src/diagram-types/stateDiagram/plugin.ts
+++ b/src/diagram-types/stateDiagram/plugin.ts
@@ -2,6 +2,22 @@ import { parseMermaid } from '@/lib/mermaidParser';
import type { DiagramPlugin } from '@/diagram-types/core';
import type { FlowNode } from '@/lib/types';
import { setNodeParent } from '@/lib/nodeParent';
+import { createId } from '@/lib/id';
+
+interface StateNoteRecord {
+ id: string;
+ target: string;
+ text: string;
+ position: 'left' | 'right' | 'over';
+}
+
+interface StateControlRecord {
+ id: string;
+ label: string;
+ kind: 'fork' | 'join';
+}
+
+const STATE_DIAGRAM_NOTE_RE = /^note\s+(left of|right of|over)\s+("?)([^":]+)\2\s*:\s*(.+)$/i;
function normalizeStateTransitionLabels(input: string): string {
const lines = input.replace(/\r\n/g, '\n').split('\n');
@@ -22,6 +38,48 @@ function normalizeStateTransitionLabels(input: string): string {
return normalized.join('\n');
}
+function extractDeclaredStateId(line: string): string | null {
+ const aliasCompositeMatch = line.match(
+ /^state\s+"([^"]+)"\s+as\s+([A-Za-z_][\w.-]*)(?:\s+<<(fork|join)>>)?\s*\{$/i
+ );
+ if (aliasCompositeMatch) {
+ return aliasCompositeMatch[2].trim();
+ }
+
+ const aliasMatch = line.match(
+ /^state\s+"([^"]+)"\s+as\s+([A-Za-z_][\w.-]*)(?:\s+<<(fork|join)>>)?\s*$/i
+ );
+ if (aliasMatch) {
+ return aliasMatch[2].trim();
+ }
+
+ const compositeMatch = line.match(/^state\s+("?)([^"{]+)\1\s*\{$/i);
+ if (compositeMatch) {
+ return compositeMatch[2].trim();
+ }
+
+ const simpleMatch = line.match(/^state\s+([A-Za-z_][\w.-]*)(?:\s+<<(fork|join)>>)?\s*$/i);
+ if (simpleMatch) {
+ return simpleMatch[1].trim();
+ }
+
+ const descriptionMatch = line.match(/^([A-Za-z_][\w.-]*)\s*:\s*(.+)$/);
+ if (descriptionMatch) {
+ return descriptionMatch[1].trim();
+ }
+
+ return null;
+}
+
+function extractTransitionStateIds(line: string): string[] {
+ const transitionMatch = line.match(/^(.+?)\s+(<-->|<--|-->|==>|-.->)\s+(.+?)(?:\s*:\s*(.+))?$/);
+ if (!transitionMatch) {
+ return [];
+ }
+
+ return [transitionMatch[1].trim(), transitionMatch[3].trim()].filter((value) => value !== '[*]');
+}
+
function collectStateDiagramDiagnostics(input: string): { diagnostics: string[]; direction?: 'TB' | 'LR' } {
const diagnostics: string[] = [];
const lines = input.replace(/\r\n/g, '\n').split('\n');
@@ -51,7 +109,10 @@ function collectStateDiagramDiagnostics(input: string): { diagnostics: string[];
}
if (/^note\b/i.test(line)) {
- diagnostics.push(`Unsupported stateDiagram note syntax at line ${lineNumber}: "${line}"`);
+ const noteMatch = line.match(STATE_DIAGRAM_NOTE_RE);
+ if (!noteMatch) {
+ diagnostics.push(`Invalid stateDiagram note syntax at line ${lineNumber}: "${line}"`);
+ }
continue;
}
@@ -86,7 +147,21 @@ function parseStateDiagram(input: string) {
const normalizedInput = normalizeStateTransitionLabels(input);
const parsed = parseMermaid(normalizedInput);
const withCompositeParents = applyCompositeStateParenting(parsed.nodes as FlowNode[], input);
- parsed.nodes = withCompositeParents;
+ const notes = parseStateDiagramNotes(input, withCompositeParents);
+ const controls = parseStateDiagramControls(input);
+ parsed.nodes = applyStateDiagramEnhancements(withCompositeParents, notes, controls);
+ parsed.edges = [
+ ...parsed.edges,
+ ...notes.map((note) => ({
+ id: createId(`e-state-note-${note.id}-${note.target}`),
+ source: note.id,
+ target: note.target,
+ type: 'straight',
+ data: {
+ dashPattern: 'dashed' as const,
+ },
+ })),
+ ];
if (direction) {
parsed.direction = direction;
}
@@ -111,6 +186,25 @@ function applyCompositeStateParenting(nodes: FlowNode[], input: string): FlowNod
continue;
}
+ const compositeAliasMatch = line.match(
+ /^state\s+"([^"]+)"\s+as\s+([A-Za-z_][\w.-]*)\s*\{$/i
+ );
+ if (compositeAliasMatch) {
+ const parentId = compositeAliasMatch[2].trim();
+ compositeStack.push(parentId);
+
+ if (!nodeIndexById.has(parentId)) {
+ nodeIndexById.set(parentId, nextNodes.length);
+ nextNodes.push({
+ id: parentId,
+ type: 'state',
+ position: { x: 0, y: 0 },
+ data: { label: compositeAliasMatch[1].trim() || parentId },
+ } as FlowNode);
+ }
+ continue;
+ }
+
const compositeMatch = line.match(/^state\s+("?)([^"{]+)\1\s*\{$/i);
if (compositeMatch) {
const parentId = compositeMatch[2].trim();
@@ -138,16 +232,14 @@ function applyCompositeStateParenting(nodes: FlowNode[], input: string): FlowNod
continue;
}
- const transitionMatch = line.match(/^(.+?)\s+(<-->|<--|-->|==>|-.->)\s+(.+?)(?:\s*:\s*(.+))?$/);
- if (!transitionMatch) {
- continue;
+ const declaredStateIds = new Set();
+ const declaredStateId = extractDeclaredStateId(line);
+ if (declaredStateId) {
+ declaredStateIds.add(declaredStateId);
}
+ extractTransitionStateIds(line).forEach((stateId) => declaredStateIds.add(stateId));
- const stateIds = [transitionMatch[1].trim(), transitionMatch[3].trim()].filter(
- (value) => value !== '[*]'
- );
-
- for (const stateId of stateIds) {
+ for (const stateId of declaredStateIds) {
const nodeIndex = nodeIndexById.get(stateId);
if (typeof nodeIndex !== 'number') {
continue;
@@ -159,6 +251,127 @@ function applyCompositeStateParenting(nodes: FlowNode[], input: string): FlowNod
return nextNodes;
}
+function parseStateDiagramNotes(input: string, nodes: FlowNode[]): StateNoteRecord[] {
+ const knownNodeIds = new Set(nodes.map((node) => node.id));
+ const notes: StateNoteRecord[] = [];
+
+ input
+ .replace(/\r\n/g, '\n')
+ .split('\n')
+ .forEach((rawLine, index) => {
+ const line = rawLine.trim();
+ const match = line.match(STATE_DIAGRAM_NOTE_RE);
+ if (!match || !knownNodeIds.has(match[3])) {
+ return;
+ }
+
+ notes.push({
+ id: `state-note-${index + 1}`,
+ target: match[3],
+ text: match[4].trim(),
+ position: match[1].toLowerCase().replace(' of', '') as StateNoteRecord['position'],
+ });
+ });
+
+ return notes;
+}
+
+function parseStateDiagramControls(input: string): StateControlRecord[] {
+ const controls: StateControlRecord[] = [];
+
+ input
+ .replace(/\r\n/g, '\n')
+ .split('\n')
+ .forEach((rawLine) => {
+ const line = rawLine.trim();
+ const aliasMatch = line.match(
+ /^state\s+"([^"]+)"\s+as\s+([A-Za-z_][\w.-]*)\s+<<(fork|join)>>\s*$/i
+ );
+ if (aliasMatch) {
+ controls.push({
+ id: aliasMatch[2],
+ label: aliasMatch[1],
+ kind: aliasMatch[3].toLowerCase() as StateControlRecord['kind'],
+ });
+ return;
+ }
+
+ const simpleMatch = line.match(/^state\s+([A-Za-z_][\w.-]*)\s+<<(fork|join)>>\s*$/i);
+ if (simpleMatch) {
+ controls.push({
+ id: simpleMatch[1],
+ label: simpleMatch[2].toLowerCase() === 'fork' ? 'Fork' : 'Join',
+ kind: simpleMatch[2].toLowerCase() as StateControlRecord['kind'],
+ });
+ }
+ });
+
+ return controls;
+}
+
+function applyStateDiagramEnhancements(
+ nodes: FlowNode[],
+ notes: StateNoteRecord[],
+ controls: StateControlRecord[]
+): FlowNode[] {
+ const nextNodes = [...nodes];
+ const nodeIndexById = new Map(nextNodes.map((node, index) => [node.id, index]));
+
+ controls.forEach((control) => {
+ const existingIndex = nodeIndexById.get(control.id);
+ const baseData = {
+ label: control.label,
+ color: control.kind === 'fork' ? 'slate' : 'blue',
+ shape: 'rectangle' as const,
+ width: 120,
+ height: 52,
+ stateControlKind: control.kind,
+ };
+
+ if (typeof existingIndex === 'number') {
+ nextNodes[existingIndex] = {
+ ...nextNodes[existingIndex],
+ type: 'process',
+ data: {
+ ...nextNodes[existingIndex].data,
+ ...baseData,
+ },
+ };
+ return;
+ }
+
+ nodeIndexById.set(control.id, nextNodes.length);
+ nextNodes.push({
+ id: control.id,
+ type: 'process',
+ position: { x: 0, y: 0 },
+ data: baseData,
+ } as FlowNode);
+ });
+
+ notes.forEach((note) => {
+ const targetNode = nextNodes.find((node) => node.id === note.target);
+ const offsetX = note.position === 'left' ? -220 : note.position === 'right' ? 220 : 0;
+ const offsetY = note.position === 'over' ? -90 : 0;
+ nextNodes.push({
+ id: note.id,
+ type: 'annotation',
+ position: {
+ x: (targetNode?.position.x ?? 0) + offsetX,
+ y: (targetNode?.position.y ?? 0) + offsetY,
+ },
+ data: {
+ label: note.text,
+ color: 'yellow',
+ stateNotePosition: note.position,
+ stateNoteTarget: note.target,
+ },
+ } as FlowNode);
+ });
+
+ return nextNodes;
+}
+
export const STATE_DIAGRAM_PLUGIN: DiagramPlugin = {
id: 'stateDiagram',
displayName: 'State Diagram',
diff --git a/src/hooks/ai-generation/requestLifecycle.ts b/src/hooks/ai-generation/requestLifecycle.ts
index 3a6cab81..e374c271 100644
--- a/src/hooks/ai-generation/requestLifecycle.ts
+++ b/src/hooks/ai-generation/requestLifecycle.ts
@@ -3,13 +3,13 @@ import { serializeCanvasContextForAI } from '@/services/ai/contextSerializer';
import { generateDiagramFromChat, type ChatMessage } from '@/services/aiService';
import type { FlowEdge, FlowNode, GlobalEdgeOptions } from '@/lib/types';
import type { AISettings } from '@/store/types';
+import { buildIdMap, parseDslOrThrow, toFinalEdges, toFinalNodes } from './graphComposer';
import {
- buildIdMap,
- parseDslOrThrow,
- toFinalEdges,
- toFinalNodes,
-} from './graphComposer';
-import { applyAIResultToCanvas, positionNewNodesSmartly, restoreExistingPositions } from './positionPreservingApply';
+ applyAIResultToCanvas,
+ positionNewNodesSmartly,
+ restoreExistingPositions,
+} from './positionPreservingApply';
+import { enrichNodesWithIcons } from '@/lib/nodeEnricher';
interface GenerateAIFlowResultParams {
chatMessages: ChatMessage[];
@@ -34,7 +34,12 @@ function isRetryableError(error: unknown): boolean {
if (error instanceof Error) {
const msg = error.message.toLowerCase();
// Retry on rate-limit and network errors, not on auth or parse errors
- return msg.includes('429') || msg.includes('rate') || msg.includes('network') || msg.includes('fetch');
+ return (
+ msg.includes('429') ||
+ msg.includes('rate') ||
+ msg.includes('network') ||
+ msg.includes('fetch')
+ );
}
return false;
}
@@ -42,7 +47,7 @@ function isRetryableError(error: unknown): boolean {
async function withRetry(
fn: () => Promise,
signal: AbortSignal | undefined,
- onRetry?: (attempt: number) => void,
+ onRetry?: (attempt: number) => void
): Promise {
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
@@ -69,9 +74,11 @@ export interface GenerateAIFlowResult {
export function buildUserChatMessage(prompt: string, imageBase64?: string): ChatMessage {
return {
role: 'user',
- parts: [{
- text: imageBase64 ? `${prompt} [Image Attached]` : prompt,
- }],
+ parts: [
+ {
+ text: imageBase64 ? `${prompt} [Image Attached]` : prompt,
+ },
+ ],
};
}
@@ -82,11 +89,7 @@ export function appendChatExchange(
editMode = false
): ChatMessage[] {
const modelText = editMode ? '[Diagram updated]' : dslText;
- return [
- ...previousMessages,
- userMessage,
- { role: 'model', parts: [{ text: modelText }] },
- ];
+ return [...previousMessages, userMessage, { role: 'model', parts: [{ text: modelText }] }];
}
function buildSelectionPromptSuffix(selectedNodeIds: string[], nodes: FlowNode[]): string {
@@ -128,22 +131,23 @@ export async function generateAIFlowResult({
for (let attempt = 0; attempt <= 1; attempt++) {
dslText = await withRetry(
- () => generateDiagramFromChat(
- chatMessages,
- activePrompt,
- currentGraph,
- imageBase64,
- aiSettings.apiKey,
- aiSettings.model,
- aiSettings.provider || 'gemini',
- aiSettings.customBaseUrl,
- isEditMode,
- onChunk,
- signal,
- aiSettings.temperature,
- ),
+ () =>
+ generateDiagramFromChat(
+ chatMessages,
+ activePrompt,
+ currentGraph,
+ imageBase64,
+ aiSettings.apiKey,
+ aiSettings.model,
+ aiSettings.provider || 'gemini',
+ aiSettings.customBaseUrl,
+ isEditMode,
+ onChunk,
+ signal,
+ aiSettings.temperature
+ ),
signal,
- onRetry,
+ onRetry
);
try {
parsed = parseDslOrThrow(dslText);
@@ -157,7 +161,7 @@ export async function generateAIFlowResult({
}
parsed = parsed!;
const idMap = buildIdMap(parsed.nodes, nodes);
- const finalNodes = toFinalNodes(parsed.nodes, idMap);
+ const finalNodes = await enrichNodesWithIcons(toFinalNodes(parsed.nodes, idMap));
const finalEdges = toFinalEdges(parsed.edges, idMap, globalEdgeOptions);
const isEmptyCanvas = nodes.length === 0;
@@ -167,7 +171,12 @@ export async function generateAIFlowResult({
finalEdges,
{ direction: 'TB', algorithm: 'mrtree', spacing: 'loose' }
);
- return { dslText, userMessage: buildUserChatMessage(prompt, imageBase64), layoutedNodes, layoutedEdges };
+ return {
+ dslText,
+ userMessage: buildUserChatMessage(prompt, imageBase64),
+ layoutedNodes,
+ layoutedEdges,
+ };
}
// Position-preserving apply: matched nodes keep their positions, new nodes get ELK positions
@@ -188,7 +197,12 @@ export async function generateAIFlowResult({
}
// Smart placement: position new nodes near their existing neighbors
- const smartPositioned = positionNewNodesSmartly(mergedNodes, mergedEdges, newNodeIds, existingById);
+ const smartPositioned = positionNewNodesSmartly(
+ mergedNodes,
+ mergedEdges,
+ newNodeIds,
+ existingById
+ );
const unplacedIds = [...newNodeIds].filter((id) => {
const node = smartPositioned.find((n) => n.id === id);
return !node?.position || (node.position.x === 0 && node.position.y === 0);
diff --git a/src/hooks/node-operations/sectionBounds.ts b/src/hooks/node-operations/sectionBounds.ts
index 518c352a..3cf530a8 100644
--- a/src/hooks/node-operations/sectionBounds.ts
+++ b/src/hooks/node-operations/sectionBounds.ts
@@ -2,9 +2,9 @@ import type { FlowNode } from '@/lib/types';
import { getNodeParentId } from '@/lib/nodeParent';
import { resolveNodeSize } from '@/components/nodeHelpers';
-export const SECTION_MIN_WIDTH = 500;
-export const SECTION_MIN_HEIGHT = 400;
-export const SECTION_PADDING_X = 32;
+export const SECTION_MIN_WIDTH = 200;
+export const SECTION_MIN_HEIGHT = 160;
+export const SECTION_PADDING_X = 20;
export const SECTION_PADDING_BOTTOM = 32;
// Title now floats ABOVE the section border โ no internal header space needed
export const SECTION_HEADER_HEIGHT = 16;
diff --git a/src/hooks/node-operations/utils.test.ts b/src/hooks/node-operations/utils.test.ts
index cd5c76e7..cbdf787e 100644
--- a/src/hooks/node-operations/utils.test.ts
+++ b/src/hooks/node-operations/utils.test.ts
@@ -53,12 +53,12 @@ describe('section node utilities', () => {
expect(section).toBeTruthy();
expect(section?.type).toBe('section');
- expect(section?.position).toEqual({ x: 168, y: 164 });
- expect(section?.style).toMatchObject({ width: 500, height: 400 });
+ expect(section?.position).toEqual({ x: 180, y: 164 });
+ expect(section?.style).toMatchObject({ width: 340, height: 188 });
expect(childA?.parentId).toBe('section-1');
- expect(childA?.extent).toBe('parent');
- expect(childA?.position).toEqual({ x: 32, y: 16 });
- expect(childB?.position).toEqual({ x: 212, y: 96 });
+ expect(childA?.extent).toBeUndefined();
+ expect(childA?.position).toEqual({ x: 20, y: 16 });
+ expect(childB?.position).toEqual({ x: 200, y: 96 });
});
it('parents a dragged node into the deepest section without auto-fitting manual sections', () => {
@@ -112,7 +112,9 @@ describe('section node utilities', () => {
const innerSection = makeSectionNode('section-inner', 180, 180, 500, 400);
const draggedNode = makeProcessNode('node-a', 240, 260);
- expect(getContainingSectionId([outerSection, innerSection, draggedNode], draggedNode)).toBe('section-inner');
+ expect(getContainingSectionId([outerSection, innerSection, draggedNode], draggedNode)).toBe(
+ 'section-inner'
+ );
});
it('duplicates a section with its descendants and preserves hierarchy', () => {
@@ -131,8 +133,12 @@ describe('section node utilities', () => {
const duplicatedNodes = duplicateSectionWithChildren([section, child, grandChild], 'section-1');
const duplicatedSections = duplicatedNodes.filter((node) => node.type === 'section');
const clonedSection = duplicatedSections.find((node) => node.id !== 'section-1');
- const clonedChild = duplicatedNodes.find((node) => node.id !== 'child-1' && node.parentId === clonedSection?.id);
- const clonedGrandChild = duplicatedNodes.find((node) => node.id !== 'grandchild-1' && node.parentId === clonedChild?.id);
+ const clonedChild = duplicatedNodes.find(
+ (node) => node.id !== 'child-1' && node.parentId === clonedSection?.id
+ );
+ const clonedGrandChild = duplicatedNodes.find(
+ (node) => node.id !== 'grandchild-1' && node.parentId === clonedChild?.id
+ );
expect(duplicatedSections).toHaveLength(2);
expect(clonedSection?.position).toEqual({ x: 180, y: 200 });
@@ -169,7 +175,7 @@ describe('section node utilities', () => {
const fittedNodes = fitSectionToChildren(section, [section, child]);
const fittedSection = fittedNodes.find((node) => node.id === 'section-1');
- expect(fittedSection?.position).toEqual({ x: 308, y: 264 });
+ expect(fittedSection?.position).toEqual({ x: 320, y: 264 });
});
it('releases a child from its parent section while preserving absolute position', () => {
diff --git a/src/hooks/useFlowEditorCallbacks.ts b/src/hooks/useFlowEditorCallbacks.ts
index def03d94..063df945 100644
--- a/src/hooks/useFlowEditorCallbacks.ts
+++ b/src/hooks/useFlowEditorCallbacks.ts
@@ -1,155 +1,189 @@
import { startTransition, useCallback, useRef } from 'react';
import type { FlowEdge, FlowNode, FlowSnapshot } from '@/lib/types';
+import { enrichNodesWithIcons } from '@/lib/nodeEnricher';
+import { normalizeNodeIconData } from '@/lib/nodeIconState';
import { useFlowStore } from '@/store';
import { composeDiagramForDisplay } from '@/services/composeDiagramForDisplay';
interface UseFlowEditorCallbacksParams {
- addPage: () => string;
- closePage: (pageId: string) => void;
- reorderPage: (draggedPageId: string, targetPageId: string) => void;
- updatePage: (pageId: string, update: Partial<{ name: string }>) => void;
- navigate: (path: string) => void;
- pagesLength: number;
- cannotCloseLastTabMessage: string;
- setNodes: (nodes: FlowNode[] | ((nodes: FlowNode[]) => FlowNode[])) => void;
- setEdges: (edges: FlowEdge[] | ((edges: FlowEdge[]) => FlowEdge[])) => void;
- restoreSnapshot: (snapshot: FlowSnapshot, setNodes: UseFlowEditorCallbacksParams['setNodes'], setEdges: UseFlowEditorCallbacksParams['setEdges']) => void;
- recordHistory: () => void;
- fitView: (options?: { duration?: number; padding?: number }) => void;
- screenToFlowPosition: (position: { x: number; y: number }) => { x: number; y: number };
+ addPage: () => string;
+ closePage: (pageId: string) => void;
+ reorderPage: (draggedPageId: string, targetPageId: string) => void;
+ updatePage: (pageId: string, update: Partial<{ name: string }>) => void;
+ navigate: (path: string) => void;
+ pagesLength: number;
+ cannotCloseLastTabMessage: string;
+ setNodes: (nodes: FlowNode[] | ((nodes: FlowNode[]) => FlowNode[])) => void;
+ setEdges: (edges: FlowEdge[] | ((edges: FlowEdge[]) => FlowEdge[])) => void;
+ restoreSnapshot: (
+ snapshot: FlowSnapshot,
+ setNodes: UseFlowEditorCallbacksParams['setNodes'],
+ setEdges: UseFlowEditorCallbacksParams['setEdges']
+ ) => void;
+ recordHistory: () => void;
+ fitView: (options?: { duration?: number; padding?: number }) => void;
+ screenToFlowPosition: (position: { x: number; y: number }) => { x: number; y: number };
}
interface UseFlowEditorCallbacksResult {
- getCenter: () => { x: number; y: number };
- handleSwitchPage: (pageId: string) => void;
- handleAddPage: () => void;
- handleClosePage: (pageId: string) => void;
- handleRenamePage: (pageId: string, newName: string) => void;
- handleReorderPage: (draggedPageId: string, targetPageId: string) => void;
- selectAll: () => void;
- handleRestoreSnapshot: (snapshot: FlowSnapshot) => void;
- handleCommandBarApply: (newNodes: FlowNode[], newEdges: FlowEdge[]) => void;
+ getCenter: () => { x: number; y: number };
+ handleSwitchPage: (pageId: string) => void;
+ handleAddPage: () => void;
+ handleClosePage: (pageId: string) => void;
+ handleRenamePage: (pageId: string, newName: string) => void;
+ handleReorderPage: (draggedPageId: string, targetPageId: string) => void;
+ selectAll: () => void;
+ handleRestoreSnapshot: (snapshot: FlowSnapshot) => void;
+ handleCommandBarApply: (newNodes: FlowNode[], newEdges: FlowEdge[]) => void;
}
export function useFlowEditorCallbacks({
- addPage,
- closePage,
- reorderPage,
- updatePage,
- navigate,
- pagesLength,
- cannotCloseLastTabMessage,
- setNodes,
- setEdges,
- restoreSnapshot,
- recordHistory,
- fitView,
- screenToFlowPosition,
+ addPage,
+ closePage,
+ reorderPage,
+ updatePage,
+ navigate,
+ pagesLength,
+ cannotCloseLastTabMessage,
+ setNodes,
+ setEdges,
+ restoreSnapshot,
+ recordHistory,
+ fitView,
+ screenToFlowPosition,
}: UseFlowEditorCallbacksParams): UseFlowEditorCallbacksResult {
- const stabilizationRunIdRef = useRef(0);
-
- const getCenter = useCallback(() => {
- const centerX = window.innerWidth / 2;
- const centerY = window.innerHeight / 2;
- return screenToFlowPosition({ x: centerX, y: centerY });
- }, [screenToFlowPosition]);
-
- const handleSwitchPage = useCallback((pageId: string) => {
- navigate(`/flow/${pageId}`);
- }, [navigate]);
-
- const handleAddPage = useCallback(() => {
- const newId = addPage();
- navigate(`/flow/${newId}`);
- }, [addPage, navigate]);
-
- const handleClosePage = useCallback((pageId: string) => {
- if (pagesLength === 1) {
- alert(cannotCloseLastTabMessage);
+ const stabilizationRunIdRef = useRef(0);
+
+ const getCenter = useCallback(() => {
+ const centerX = window.innerWidth / 2;
+ const centerY = window.innerHeight / 2;
+ return screenToFlowPosition({ x: centerX, y: centerY });
+ }, [screenToFlowPosition]);
+
+ const handleSwitchPage = useCallback(
+ (pageId: string) => {
+ navigate(`/flow/${pageId}`);
+ },
+ [navigate]
+ );
+
+ const handleAddPage = useCallback(() => {
+ const newId = addPage();
+ navigate(`/flow/${newId}`);
+ }, [addPage, navigate]);
+
+ const handleClosePage = useCallback(
+ (pageId: string) => {
+ if (pagesLength === 1) {
+ alert(cannotCloseLastTabMessage);
+ return;
+ }
+ closePage(pageId);
+ },
+ [cannotCloseLastTabMessage, closePage, pagesLength]
+ );
+
+ const handleRenamePage = useCallback(
+ (pageId: string, newName: string) => {
+ updatePage(pageId, { name: newName });
+ },
+ [updatePage]
+ );
+
+ const handleReorderPage = useCallback(
+ (draggedPageId: string, targetPageId: string) => {
+ reorderPage(draggedPageId, targetPageId);
+ },
+ [reorderPage]
+ );
+
+ const selectAll = useCallback(() => {
+ setNodes((nodes) => nodes.map((node) => ({ ...node, selected: true })));
+ setEdges((edges) => edges.map((edge) => ({ ...edge, selected: true })));
+ }, [setEdges, setNodes]);
+
+ const handleRestoreSnapshot = useCallback(
+ (snapshot: FlowSnapshot) => {
+ restoreSnapshot(snapshot, setNodes, setEdges);
+ recordHistory();
+ },
+ [recordHistory, restoreSnapshot, setEdges, setNodes]
+ );
+
+ const handleCommandBarApply = useCallback(
+ async (newNodes: FlowNode[], newEdges: FlowEdge[]) => {
+ const enrichedNodes = (await enrichNodesWithIcons(newNodes)).map((node) => ({
+ ...node,
+ data: normalizeNodeIconData(node.data),
+ }));
+ recordHistory();
+ startTransition(() => {
+ setNodes(
+ enrichedNodes.map((node, index) => ({
+ ...node,
+ data: { ...node.data, freshlyAdded: true, animateDelay: Math.min(index * 20, 400) },
+ }))
+ );
+ setEdges(newEdges);
+ });
+ setTimeout(() => fitView({ duration: 800, padding: 0.2 }), 100);
+
+ const runId = stabilizationRunIdRef.current + 1;
+ stabilizationRunIdRef.current = runId;
+
+ window.setTimeout(() => {
+ void (async () => {
+ if (stabilizationRunIdRef.current !== runId) {
return;
- }
- closePage(pageId);
- }, [cannotCloseLastTabMessage, closePage, pagesLength]);
-
- const handleRenamePage = useCallback((pageId: string, newName: string) => {
- updatePage(pageId, { name: newName });
- }, [updatePage]);
-
- const handleReorderPage = useCallback((draggedPageId: string, targetPageId: string) => {
- reorderPage(draggedPageId, targetPageId);
- }, [reorderPage]);
-
- const selectAll = useCallback(() => {
- setNodes((nodes) => nodes.map((node) => ({ ...node, selected: true })));
- setEdges((edges) => edges.map((edge) => ({ ...edge, selected: true })));
- }, [setEdges, setNodes]);
-
- const handleRestoreSnapshot = useCallback((snapshot: FlowSnapshot) => {
- restoreSnapshot(snapshot, setNodes, setEdges);
- recordHistory();
- }, [recordHistory, restoreSnapshot, setEdges, setNodes]);
-
- const handleCommandBarApply = useCallback((newNodes: FlowNode[], newEdges: FlowEdge[]) => {
- recordHistory();
- startTransition(() => {
- setNodes(newNodes.map((node, index) => ({
- ...node,
- data: { ...node.data, freshlyAdded: true, animateDelay: Math.min(index * 20, 400) },
- })));
- setEdges(newEdges);
- });
- setTimeout(() => fitView({ duration: 800, padding: 0.2 }), 100);
-
- const runId = stabilizationRunIdRef.current + 1;
- stabilizationRunIdRef.current = runId;
-
- window.setTimeout(() => {
- void (async () => {
- if (stabilizationRunIdRef.current !== runId) {
- return;
- }
-
- const state = useFlowStore.getState();
- const measuredNodes = state.nodes;
- const measuredEdges = state.edges;
- const hasMeasuredDimensions = measuredNodes.some((node) => {
- const measured = (node as FlowNode & {
- measured?: { width?: number; height?: number };
- }).measured;
- return typeof measured?.width === 'number' && typeof measured?.height === 'number';
- });
-
- if (!hasMeasuredDimensions) {
- return;
- }
-
- const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId);
- const { nodes: stabilizedNodes, edges: stabilizedEdges } = await composeDiagramForDisplay(
- measuredNodes,
- measuredEdges,
- { diagramType: activeTab?.diagramType }
- );
-
- if (stabilizationRunIdRef.current !== runId) {
- return;
- }
-
- setNodes(stabilizedNodes);
- setEdges(stabilizedEdges);
- fitView({ duration: 500, padding: 0.2 });
- })();
- }, 180);
- }, [fitView, recordHistory, setEdges, setNodes]);
-
- return {
- getCenter,
- handleSwitchPage,
- handleAddPage,
- handleClosePage,
- handleRenamePage,
- handleReorderPage,
- selectAll,
- handleRestoreSnapshot,
- handleCommandBarApply,
- };
+ }
+
+ const state = useFlowStore.getState();
+ const measuredNodes = state.nodes;
+ const measuredEdges = state.edges;
+ const hasMeasuredDimensions = measuredNodes.some((node) => {
+ const measured = (
+ node as FlowNode & {
+ measured?: { width?: number; height?: number };
+ }
+ ).measured;
+ return typeof measured?.width === 'number' && typeof measured?.height === 'number';
+ });
+
+ if (!hasMeasuredDimensions) {
+ return;
+ }
+
+ const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId);
+ const { clearLayoutCache } = await import('@/services/elkLayout');
+ clearLayoutCache();
+ const { nodes: stabilizedNodes, edges: stabilizedEdges } = await composeDiagramForDisplay(
+ measuredNodes,
+ measuredEdges,
+ { diagramType: activeTab?.diagramType }
+ );
+
+ if (stabilizationRunIdRef.current !== runId) {
+ return;
+ }
+
+ setNodes(stabilizedNodes);
+ setEdges(stabilizedEdges);
+ fitView({ duration: 500, padding: 0.2 });
+ })();
+ }, 180);
+ },
+ [fitView, recordHistory, setEdges, setNodes]
+ );
+
+ return {
+ getCenter,
+ handleSwitchPage,
+ handleAddPage,
+ handleClosePage,
+ handleRenamePage,
+ handleReorderPage,
+ selectAll,
+ handleRestoreSnapshot,
+ handleCommandBarApply,
+ };
}
diff --git a/src/hooks/useFlowEditorUIState.ts b/src/hooks/useFlowEditorUIState.ts
index d3ff84d6..f20be1a6 100644
--- a/src/hooks/useFlowEditorUIState.ts
+++ b/src/hooks/useFlowEditorUIState.ts
@@ -42,7 +42,7 @@ export function useFlowEditorUIState(): UseFlowEditorUIStateResult {
const [commandBarView, setCommandBarView] = useState('root');
const [editorMode, setEditorMode] = useState('canvas');
const [studioTab, setStudioTab] = useState('ai');
- const [studioCodeMode, setStudioCodeMode] = useState('openflow');
+ const [studioCodeMode, setStudioCodeMode] = useState('mermaid');
const [isSelectMode, setIsSelectMode] = useState(true);
const [isArchitectureRulesOpen, setIsArchitectureRulesOpen] = useState(false);
diff --git a/src/lib/aiIconsPipeline.test.ts b/src/lib/aiIconsPipeline.test.ts
new file mode 100644
index 00000000..6a08ad38
--- /dev/null
+++ b/src/lib/aiIconsPipeline.test.ts
@@ -0,0 +1,154 @@
+import { describe, expect, it } from 'vitest';
+import { parseOpenFlowDslV2 } from './flowmindDSLParserV2';
+import { enrichNodesWithIcons } from './nodeEnricher';
+
+// These test the full pipeline: AI-generated DSL โ parse โ enrich โ correct icons
+// Simulates what happens when AI outputs DSL with archProvider/archResourceType
+
+describe('AI + Icons Pipeline (E2E)', () => {
+ it('Node.js API with PostgreSQL and Redis', async () => {
+ const dsl = `
+ flow: Node.js Stack
+ direction: TB
+
+ [system] api: Express API { archProvider: "developer", archResourceType: "others-expressjs-dark", color: "blue" }
+ [system] db: PostgreSQL { archProvider: "developer", archResourceType: "database-postgresql", color: "violet" }
+ [system] cache: Redis { archProvider: "developer", archResourceType: "database-redis", color: "red" }
+
+ api ->|SQL| db
+ api ->|cache| cache
+ `;
+
+ const parsed = parseOpenFlowDslV2(dsl);
+ const enriched = await enrichNodesWithIcons(parsed.nodes);
+
+ expect(enriched).toHaveLength(3);
+
+ const api = enriched.find((n) => n.id === 'api');
+ expect(api?.data.archIconPackId).toBe('developer-icons-v1');
+ expect(api?.data.archIconShapeId).toBe('others-expressjs-dark');
+
+ const db = enriched.find((n) => n.id === 'db');
+ expect(db?.data.archIconPackId).toBe('developer-icons-v1');
+ expect(db?.data.archIconShapeId).toBe('database-postgresql');
+
+ const cache = enriched.find((n) => n.id === 'cache');
+ expect(cache?.data.archIconPackId).toBe('developer-icons-v1');
+ expect(cache?.data.archIconShapeId).toContain('redis');
+ });
+
+ it('AWS Lambda โ SQS โ DynamoDB', async () => {
+ const dsl = `
+ flow: Serverless Pipeline
+ direction: TB
+
+ [architecture] lambda: Lambda { archProvider: "aws", archResourceType: "compute-lambda", color: "violet" }
+ [architecture] sqs: SQS Queue { archProvider: "aws", archResourceType: "app-integration-sqs", color: "amber" }
+ [architecture] dynamo: DynamoDB { archProvider: "aws", archResourceType: "database-dynamodb", color: "emerald" }
+
+ lambda ->|publish| sqs
+ sqs ->|write| dynamo
+ `;
+
+ const parsed = parseOpenFlowDslV2(dsl);
+ const enriched = await enrichNodesWithIcons(parsed.nodes);
+
+ expect(enriched).toHaveLength(3);
+
+ for (const node of enriched) {
+ expect(node.data.archIconPackId).toBe('aws-official-starter-v1');
+ expect(node.data.archIconShapeId).toBeTruthy();
+ }
+ });
+
+ it('React โ Express โ MongoDB โ S3 (mixed stacks)', async () => {
+ const dsl = `
+ flow: Full Stack
+ direction: TB
+
+ [system] react: React App { archProvider: "developer", archResourceType: "frontend-react", color: "blue" }
+ [system] api: Express { archProvider: "developer", archResourceType: "others-expressjs-dark", color: "violet" }
+ [system] mongo: MongoDB { archProvider: "developer", archResourceType: "database-mongodb", color: "emerald" }
+ [architecture] s3: S3 Storage { archProvider: "aws", archResourceType: "storage-s3", color: "amber" }
+
+ react ->|HTTP| api
+ api ->|query| mongo
+ api ->|upload| s3
+ `;
+
+ const parsed = parseOpenFlowDslV2(dsl);
+ const enriched = await enrichNodesWithIcons(parsed.nodes);
+
+ expect(enriched).toHaveLength(4);
+
+ const react = enriched.find((n) => n.id === 'react');
+ expect(react?.data.archIconPackId).toBe('developer-icons-v1');
+ expect(react?.data.color).toBe('blue');
+
+ const s3 = enriched.find((n) => n.id === 's3');
+ expect(s3?.data.archIconPackId).toBe('aws-official-starter-v1');
+ });
+
+ it('auto-enriches nodes without explicit icons (icons: auto behavior)', async () => {
+ const dsl = `
+ flow: Auto Icons
+ direction: TB
+
+ [system] api: Express API
+ [system] db: PostgreSQL Database
+ [system] cache: Redis Cache
+ `;
+
+ const parsed = parseOpenFlowDslV2(dsl);
+ const enriched = await enrichNodesWithIcons(parsed.nodes);
+
+ // Without explicit archProvider, enricher should match by label
+ const api = enriched.find((n) => n.id === 'api');
+ expect(api?.data.archIconPackId).toBeTruthy();
+ expect(api?.data.color).toBe('blue');
+
+ const db = enriched.find((n) => n.id === 'db');
+ expect(db?.data.color).toBe('violet');
+ });
+
+ it('enricher does not overwrite AI-set provider icons', async () => {
+ const dsl = `
+ [architecture] lambda: My Lambda { archProvider: "aws", archResourceType: "compute-lambda", color: "violet" }
+ `;
+
+ const parsed = parseOpenFlowDslV2(dsl);
+ const enriched = await enrichNodesWithIcons(parsed.nodes);
+
+ const lambda = enriched.find((n) => n.id === 'lambda');
+ expect(lambda?.data.archIconPackId).toBe('aws-official-starter-v1');
+ expect(lambda?.data.archIconShapeId).toBe('compute-lambda');
+ expect(lambda?.data.color).toBe('violet');
+ });
+
+ it('enriches architecture-beta imported nodes', async () => {
+ const dsl = `
+ flow: Architecture
+ direction: TB
+
+ [architecture] server: Express.js { color: "violet" }
+ [architecture] db: PostgreSQL { color: "violet" }
+ [architecture] cache: Redis { color: "red" }
+
+ server ->|query| db
+ server ->|cache| cache
+ `;
+
+ const parsed = parseOpenFlowDslV2(dsl);
+ const enriched = await enrichNodesWithIcons(parsed.nodes);
+
+ const server = enriched.find((n) => n.id === 'server');
+ expect(server?.data.archIconPackId).toBeTruthy();
+ expect(server?.data.color).toBe('violet');
+
+ const db = enriched.find((n) => n.id === 'db');
+ expect(db?.data.archIconPackId).toBeTruthy();
+
+ const cache = enriched.find((n) => n.id === 'cache');
+ expect(cache?.data.archIconPackId).toBeTruthy();
+ });
+});
diff --git a/src/lib/entityFields.test.ts b/src/lib/entityFields.test.ts
new file mode 100644
index 00000000..5ad2cf12
--- /dev/null
+++ b/src/lib/entityFields.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest';
+import { stringifyErField, stringifyMermaidErField } from './entityFields';
+
+describe('entityFields', () => {
+ it('keeps the editor field serializer in name-first format', () => {
+ const result = stringifyErField({
+ name: 'customer_id',
+ dataType: 'uuid',
+ isPrimaryKey: false,
+ isForeignKey: true,
+ isNotNull: false,
+ isUnique: false,
+ referencesTable: 'CUSTOMER',
+ referencesField: 'id',
+ });
+
+ expect(result).toBe('customer_id: uuid FK');
+ });
+
+ it('serializes Mermaid ER fields in Mermaid-compatible type-first format', () => {
+ const result = stringifyMermaidErField({
+ name: 'customer_id',
+ dataType: 'uuid',
+ isPrimaryKey: false,
+ isForeignKey: true,
+ isNotNull: true,
+ isUnique: true,
+ referencesTable: 'CUSTOMER',
+ referencesField: 'id',
+ });
+
+ expect(result).toBe('uuid customer_id FK UK NN REFERENCES CUSTOMER.id');
+ });
+});
diff --git a/src/lib/entityFields.ts b/src/lib/entityFields.ts
index e4e93035..8085a6d6 100644
--- a/src/lib/entityFields.ts
+++ b/src/lib/entityFields.ts
@@ -20,6 +20,16 @@ function isErField(value: unknown): value is ErField {
return Boolean(value) && typeof value === 'object' && 'name' in (value as Record);
}
+function formatMermaidReferenceTarget(field: ErField): string | null {
+ const referencesTable = field.referencesTable?.trim();
+ if (!referencesTable) {
+ return null;
+ }
+
+ const referencesField = field.referencesField?.trim();
+ return referencesField ? `${referencesTable}.${referencesField}` : referencesTable;
+}
+
export function parseErField(input: string): ErField {
const normalizedInput = input.trim();
if (!normalizedInput) {
@@ -98,6 +108,24 @@ export function stringifyErField(field: ErField): string {
return segments.join(' ').trim();
}
+export function stringifyMermaidErField(field: ErField): string {
+ const segments: string[] = [];
+ const normalizedName = field.name.trim() || 'field';
+ const normalizedType = field.dataType.trim() || 'string';
+
+ segments.push(normalizedType, normalizedName);
+ if (field.isPrimaryKey) segments.push('PK');
+ if (field.isForeignKey) segments.push('FK');
+ if (field.isUnique) segments.push('UK');
+ if (field.isNotNull) segments.push('NN');
+ const referenceTarget = formatMermaidReferenceTarget(field);
+ if (referenceTarget) {
+ segments.push('REFERENCES', referenceTarget);
+ }
+
+ return segments.join(' ').trim();
+}
+
export function formatErFieldLabel(field: ErField): string {
const parts = [field.name.trim() || 'field'];
if (field.dataType.trim()) {
diff --git a/src/lib/flowmindDSLParserV2.test.ts b/src/lib/flowmindDSLParserV2.test.ts
index 57414fef..0eeb1407 100644
--- a/src/lib/flowmindDSLParserV2.test.ts
+++ b/src/lib/flowmindDSLParserV2.test.ts
@@ -1,93 +1,156 @@
-
import { describe, it, expect } from 'vitest';
import { parseOpenFlowDslV2 } from './openFlowDslParserV2';
describe('OpenFlow DSL V2 Parser', () => {
- it('parses basic nodes and edges', () => {
- const input = `
+ it('parses basic nodes and edges', () => {
+ const input = `
[start] Start
[process] Step 1
[end] End
Start -> Step 1
Step 1 -> End
`;
- const result = parseOpenFlowDslV2(input);
- expect(result.nodes).toHaveLength(3);
- expect(result.edges).toHaveLength(2);
+ const result = parseOpenFlowDslV2(input);
+ expect(result.nodes).toHaveLength(3);
+ expect(result.edges).toHaveLength(2);
- const startNode = result.nodes.find(n => n.data.label === 'Start');
- expect(startNode).toBeDefined();
- expect(startNode?.type).toBe('start');
- });
+ const startNode = result.nodes.find((n) => n.data.label === 'Start');
+ expect(startNode).toBeDefined();
+ expect(startNode?.type).toBe('start');
+ });
- it('parses explicit IDs', () => {
- const input = `
+ it('parses explicit IDs', () => {
+ const input = `
[process] p1: Process One
[process] p2: Process Two
p1 -> p2
`;
- const result = parseOpenFlowDslV2(input);
- expect(result.nodes).toHaveLength(2);
+ const result = parseOpenFlowDslV2(input);
+ expect(result.nodes).toHaveLength(2);
- const p1 = result.nodes.find(n => n.id === 'p1');
- expect(p1).toBeDefined();
- expect(p1?.data.label).toBe('Process One');
+ const p1 = result.nodes.find((n) => n.id === 'p1');
+ expect(p1).toBeDefined();
+ expect(p1?.data.label).toBe('Process One');
- const edge = result.edges[0];
- expect(edge.source).toBe('p1');
- expect(edge.target).toBe('p2');
- });
+ const edge = result.edges[0];
+ expect(edge.source).toBe('p1');
+ expect(edge.target).toBe('p2');
+ });
- it('parses attributes', () => {
- const input = `
+ it('parses attributes', () => {
+ const input = `
[process] p1: Configured Node { color: "red", icon: "settings" }
p1 -> p2 { style: "dashed", label: "async" }
`;
- const result = parseOpenFlowDslV2(input);
-
- const p1 = result.nodes.find(n => n.id === 'p1');
- expect(p1?.data.color).toBe('red');
- expect(p1?.data.icon).toBe('settings');
-
- const edge = result.edges[0];
- expect(edge.data?.styleType).toBe('dashed'); // Attributes merged into edge data/attributes? Parser logic puts it in edge helper or data?
- // Checking parser implementation:
- // dslEdges.push({ ..., attributes })
- // finalEdges.push(createDefaultEdge(..., attributes/label?))
- // Expecting createDefaultEdge to handle it or we need to check how it's mapped.
- // In parser implementation:
- // createDefaultEdge(source, target, label, id)
- // Wait, I missed passing attributes to createDefaultEdge in my implementation!
-
- // Let's check the implementation again.
- });
-
- it('parses quoted attribute values containing commas, colons, and escapes', () => {
- const input = `
+ const result = parseOpenFlowDslV2(input);
+
+ const p1 = result.nodes.find((n) => n.id === 'p1');
+ expect(p1?.data.color).toBe('red');
+ expect(p1?.data.icon).toBe('settings');
+
+ const edge = result.edges[0];
+ expect(edge.data?.styleType).toBe('dashed'); // Attributes merged into edge data/attributes? Parser logic puts it in edge helper or data?
+ // Checking parser implementation:
+ // dslEdges.push({ ..., attributes })
+ // finalEdges.push(createDefaultEdge(..., attributes/label?))
+ // Expecting createDefaultEdge to handle it or we need to check how it's mapped.
+ // In parser implementation:
+ // createDefaultEdge(source, target, label, id)
+ // Wait, I missed passing attributes to createDefaultEdge in my implementation!
+
+ // Let's check the implementation again.
+ });
+
+ it('parses quoted attribute values containing commas, colons, and escapes', () => {
+ const input = `
[process] p1: Configured Node { icon: "server, api", note: "http://svc:8080/path", enabled: true, retries: 3, quote: "say \\"hello\\"" }
`;
- const result = parseOpenFlowDslV2(input);
-
- const p1 = result.nodes.find((node) => node.id === 'p1');
- expect(p1?.data.icon).toBe('server, api');
- expect(p1?.data.note).toBe('http://svc:8080/path');
- expect(p1?.data.enabled).toBe(true);
- expect(p1?.data.retries).toBe(3);
- expect(p1?.data.quote).toBe('say "hello"');
- });
-
- it('ignores group wrappers and keeps inner nodes flat', () => {
- const input = `
+ const result = parseOpenFlowDslV2(input);
+
+ const p1 = result.nodes.find((node) => node.id === 'p1');
+ expect(p1?.data.icon).toBe('server, api');
+ expect(p1?.data.note).toBe('http://svc:8080/path');
+ expect(p1?.data.enabled).toBe(true);
+ expect(p1?.data.retries).toBe(3);
+ expect(p1?.data.quote).toBe('say "hello"');
+ });
+
+ it('ignores group wrappers and keeps inner nodes flat', () => {
+ const input = `
group "Backend" {
[process] api: API
[database] db: DB
api -> db
}
`;
- const result = parseOpenFlowDslV2(input);
- expect(result.nodes).toHaveLength(2);
+ const result = parseOpenFlowDslV2(input);
+ expect(result.nodes).toHaveLength(2);
+
+ const api = result.nodes.find((n) => n.id === 'api');
+ expect(api?.parentId).toBeUndefined();
+ });
+
+ it('maps archProvider/archResourceType to archIconPackId/archIconShapeId', () => {
+ const input = `
+ [system] db: PostgreSQL { archProvider: "developer", archResourceType: "database-postgresql", color: "violet" }
+ [system] api: Express API { archProvider: "developer", archResourceType: "others-expressjs-dark" }
+ `;
+ const result = parseOpenFlowDslV2(input);
+ expect(result.nodes).toHaveLength(2);
+
+ const db = result.nodes.find((n) => n.id === 'db');
+ expect(db?.data.archIconPackId).toBe('developer-icons-v1');
+ expect(db?.data.archIconShapeId).toBe('database-postgresql');
+ expect(db?.data.assetPresentation).toBe('icon');
- const api = result.nodes.find(n => n.id === 'api');
- expect(api?.parentId).toBeUndefined();
- });
+ const api = result.nodes.find((n) => n.id === 'api');
+ expect(api?.data.archIconPackId).toBe('developer-icons-v1');
+ expect(api?.data.archIconShapeId).toBe('others-expressjs-dark');
+ });
+
+ it('passes provider attribute through to node data', () => {
+ const input = `
+ [architecture] lambda: API Lambda { archProvider: "aws", archResourceType: "compute-lambda", color: "violet" }
+ [architecture] rds: Database { provider: "aws", color: "violet" }
+ `;
+ const result = parseOpenFlowDslV2(input);
+ expect(result.nodes).toHaveLength(2);
+
+ const lambda = result.nodes.find((n) => n.id === 'lambda');
+ expect(lambda?.data.archIconPackId).toBe('aws-official-starter-v1');
+ expect(lambda?.data.archIconShapeId).toBe('compute-lambda');
+
+ const rds = result.nodes.find((n) => n.id === 'rds');
+ expect(rds?.data.provider).toBe('aws');
+ });
+
+ it('passes icon attribute for catalog search', () => {
+ const input = `
+ [system] cache: Redis Cache { icon: "redis", color: "red" }
+ `;
+ const result = parseOpenFlowDslV2(input);
+ const cache = result.nodes.find((n) => n.id === 'cache');
+ expect(cache?.data.icon).toBe('redis');
+ });
+
+ it('maps [architecture] to custom node type', () => {
+ const input = `
+ [architecture] lambda: Lambda { archProvider: "aws", archResourceType: "compute-lambda" }
+ `;
+ const result = parseOpenFlowDslV2(input);
+ const lambda = result.nodes.find((n) => n.id === 'lambda');
+ expect(lambda?.type).toBe('custom');
+ });
+
+ it('accepts icons: auto header in metadata', () => {
+ const input = `
+ flow: My Architecture
+ direction: TB
+ icons: auto
+ [system] api: API
+ `;
+ const result = parseOpenFlowDslV2(input);
+ expect(result.metadata.icons).toBe('auto');
+ expect(result.nodes).toHaveLength(1);
+ });
});
diff --git a/src/lib/flowmindDSLParserV2.ts b/src/lib/flowmindDSLParserV2.ts
index ab636984..5e9c29f3 100644
--- a/src/lib/flowmindDSLParserV2.ts
+++ b/src/lib/flowmindDSLParserV2.ts
@@ -1,30 +1,35 @@
import { setNodeParent } from './nodeParent';
import { NODE_DEFAULTS } from '../theme';
import type { FlowEdge, FlowNode, NodeData } from './types';
+import { KNOWN_PROVIDER_PACK_IDS } from '@/services/shapeLibrary/providerCatalog';
+
+function resolveArchPackId(provider: string): string {
+ return KNOWN_PROVIDER_PACK_IDS[provider.toLowerCase()] ?? `${provider}-processed-pack-v1`;
+}
// --- Types ---
export interface DSLNode {
- id: string;
- type: string;
- label: string;
- parentId?: string;
- attributes: Record;
+ id: string;
+ type: string;
+ label: string;
+ parentId?: string;
+ attributes: Record;
}
export interface DSLEdge {
- sourceId: string;
- targetId: string;
- label?: string;
- attributes: Record;
- type?: 'default' | 'step' | 'smoothstep' | 'straight';
+ sourceId: string;
+ targetId: string;
+ label?: string;
+ attributes: Record;
+ type?: 'default' | 'step' | 'smoothstep' | 'straight';
}
export interface DSLResult {
- nodes: FlowNode[];
- edges: FlowEdge[];
- metadata: Record;
- errors: string[];
+ nodes: FlowNode[];
+ edges: FlowEdge[];
+ metadata: Record;
+ errors: string[];
}
type DSLAttributeValue = string | number | boolean;
@@ -32,373 +37,374 @@ type DSLAttributeValue = string | number | boolean;
// --- Constants ---
const NODE_TYPE_MAP: Record = {
- start: 'start',
- process: 'process',
- decision: 'decision',
- end: 'end',
- system: 'custom',
- note: 'annotation',
- section: 'process',
- group: 'process',
- browser: 'browser',
- mobile: 'mobile',
- container: 'container', // New generic container
+ start: 'start',
+ process: 'process',
+ decision: 'decision',
+ end: 'end',
+ system: 'custom',
+ note: 'annotation',
+ section: 'process',
+ group: 'process',
+ browser: 'browser',
+ mobile: 'mobile',
+ container: 'container',
+ architecture: 'custom',
};
// --- Helpers ---
function parseAttributes(text: string): Record {
- const attributes: Record = {};
- if (!text) return attributes;
-
- const content = text.trim();
- if (!content.startsWith('{') || !content.endsWith('}')) return attributes;
-
- const inner = content.slice(1, -1);
- const pairs: string[] = [];
- let buffer = '';
- let quote: '"' | "'" | null = null;
- let escaping = false;
-
- for (const char of inner) {
- if (escaping) {
- buffer += char;
- escaping = false;
- continue;
- }
-
- if (char === '\\') {
- buffer += char;
- escaping = true;
- continue;
- }
-
- if (quote) {
- buffer += char;
- if (char === quote) {
- quote = null;
- }
- continue;
- }
+ const attributes: Record = {};
+ if (!text) return attributes;
+
+ const content = text.trim();
+ if (!content.startsWith('{') || !content.endsWith('}')) return attributes;
+
+ const inner = content.slice(1, -1);
+ const pairs: string[] = [];
+ let buffer = '';
+ let quote: '"' | "'" | null = null;
+ let escaping = false;
+
+ for (const char of inner) {
+ if (escaping) {
+ buffer += char;
+ escaping = false;
+ continue;
+ }
- if (char === '"' || char === "'") {
- quote = char;
- buffer += char;
- continue;
- }
+ if (char === '\\') {
+ buffer += char;
+ escaping = true;
+ continue;
+ }
- if (char === ',') {
- const pair = buffer.trim();
- if (pair) pairs.push(pair);
- buffer = '';
- continue;
- }
+ if (quote) {
+ buffer += char;
+ if (char === quote) {
+ quote = null;
+ }
+ continue;
+ }
- buffer += char;
+ if (char === '"' || char === "'") {
+ quote = char;
+ buffer += char;
+ continue;
}
- const trailingPair = buffer.trim();
- if (trailingPair) {
- pairs.push(trailingPair);
+ if (char === ',') {
+ const pair = buffer.trim();
+ if (pair) pairs.push(pair);
+ buffer = '';
+ continue;
}
- pairs.forEach((pair) => {
- let colonIndex = -1;
- let pairQuote: '"' | "'" | null = null;
- let pairEscaping = false;
-
- for (let index = 0; index < pair.length; index += 1) {
- const char = pair[index];
-
- if (pairEscaping) {
- pairEscaping = false;
- continue;
- }
-
- if (char === '\\') {
- pairEscaping = true;
- continue;
- }
-
- if (pairQuote) {
- if (char === pairQuote) {
- pairQuote = null;
- }
- continue;
- }
-
- if (char === '"' || char === "'") {
- pairQuote = char;
- continue;
- }
-
- if (char === ':') {
- colonIndex = index;
- break;
- }
- }
+ buffer += char;
+ }
+
+ const trailingPair = buffer.trim();
+ if (trailingPair) {
+ pairs.push(trailingPair);
+ }
+
+ pairs.forEach((pair) => {
+ let colonIndex = -1;
+ let pairQuote: '"' | "'" | null = null;
+ let pairEscaping = false;
+
+ for (let index = 0; index < pair.length; index += 1) {
+ const char = pair[index];
+
+ if (pairEscaping) {
+ pairEscaping = false;
+ continue;
+ }
- if (colonIndex <= 0) return;
-
- const key = pair.slice(0, colonIndex).trim();
- const rawValue = pair.slice(colonIndex + 1).trim();
- if (!key || !rawValue) return;
-
- let value: DSLAttributeValue = rawValue;
- if (
- (value.startsWith('"') && value.endsWith('"'))
- || (value.startsWith("'") && value.endsWith("'"))
- ) {
- value = value
- .slice(1, -1)
- .replace(/\\(["'])/g, '$1')
- .replace(/\\\\/g, '\\');
- } else if (!Number.isNaN(Number(value))) {
- value = Number(value);
- } else if (value === 'true') {
- value = true;
- } else if (value === 'false') {
- value = false;
+ if (char === '\\') {
+ pairEscaping = true;
+ continue;
+ }
+
+ if (pairQuote) {
+ if (char === pairQuote) {
+ pairQuote = null;
}
+ continue;
+ }
+
+ if (char === '"' || char === "'") {
+ pairQuote = char;
+ continue;
+ }
+
+ if (char === ':') {
+ colonIndex = index;
+ break;
+ }
+ }
- attributes[key] = value;
- });
+ if (colonIndex <= 0) return;
+
+ const key = pair.slice(0, colonIndex).trim();
+ const rawValue = pair.slice(colonIndex + 1).trim();
+ if (!key || !rawValue) return;
+
+ let value: DSLAttributeValue = rawValue;
+ if (
+ (value.startsWith('"') && value.endsWith('"')) ||
+ (value.startsWith("'") && value.endsWith("'"))
+ ) {
+ value = value
+ .slice(1, -1)
+ .replace(/\\(["'])/g, '$1')
+ .replace(/\\\\/g, '\\');
+ } else if (!Number.isNaN(Number(value))) {
+ value = Number(value);
+ } else if (value === 'true') {
+ value = true;
+ } else if (value === 'false') {
+ value = false;
+ }
- return attributes;
-};
+ attributes[key] = value;
+ });
+
+ return attributes;
+}
// --- Parser ---
export function parseOpenFlowDslV2(input: string): DSLResult {
- const dslNodes: DSLNode[] = [];
- const dslEdges: DSLEdge[] = [];
- const metadata: Record = { direction: 'TB' };
- const errors: string[] = [];
-
- const lines = input.split('\n');
- const currentGroupStack: string[] = [];
-
- // First pass: symbols and structure
- // We need map label -> ID for implicit IDs
- const labelToIdMap = new Map();
-
- lines.forEach((rawLine, lineIndex) => {
- const line = rawLine.trim();
- if (!line || line.startsWith('#')) return;
-
- // 1. Metadata: key: value
- const metadataMatch = line.match(/^([a-zA-Z0-9_]+):\s*(.+)$/);
- // Avoid matching "label: value" inside node defs, heuristic: no brackets, no arrow
- if (metadataMatch && !line.includes('[') && !line.includes('->')) {
- const key = metadataMatch[1].toLowerCase();
- let value = metadataMatch[2].trim();
- // Strip quotes if present
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
- value = value.slice(1, -1);
- }
- metadata[key] = value;
- return;
- }
-
- // 2. Groups Start: group "Label" {
- const groupStartMatch = line.match(/^group\s+"?([^"{]+)"?\s*\{$/);
- if (groupStartMatch) {
- currentGroupStack.push(groupStartMatch[1]);
- return;
- }
+ const dslNodes: DSLNode[] = [];
+ const dslEdges: DSLEdge[] = [];
+ const metadata: Record = { direction: 'TB' };
+ const errors: string[] = [];
+
+ const lines = input.split('\n');
+ const currentGroupStack: string[] = [];
+
+ // First pass: symbols and structure
+ // We need map label -> ID for implicit IDs
+ const labelToIdMap = new Map();
+
+ lines.forEach((rawLine, lineIndex) => {
+ const line = rawLine.trim();
+ if (!line || line.startsWith('#')) return;
+
+ // 1. Metadata: key: value
+ const metadataMatch = line.match(/^([a-zA-Z0-9_]+):\s*(.+)$/);
+ // Avoid matching "label: value" inside node defs, heuristic: no brackets, no arrow
+ if (metadataMatch && !line.includes('[') && !line.includes('->')) {
+ const key = metadataMatch[1].toLowerCase();
+ let value = metadataMatch[2].trim();
+ // Strip quotes if present
+ if (
+ (value.startsWith('"') && value.endsWith('"')) ||
+ (value.startsWith("'") && value.endsWith("'"))
+ ) {
+ value = value.slice(1, -1);
+ }
+ metadata[key] = value;
+ return;
+ }
- // 3. Group End: }
- if (line === '}') {
- if (currentGroupStack.length > 0) {
- currentGroupStack.pop();
- } else {
- errors.push(`Line ${lineIndex + 1}: Unexpected '}'`);
- }
- return;
- }
+ // 2. Groups Start: group "Label" {
+ const groupStartMatch = line.match(/^group\s+"?([^"{]+)"?\s*\{$/);
+ if (groupStartMatch) {
+ currentGroupStack.push(groupStartMatch[1]);
+ return;
+ }
- // 4. Edges: A -> B { attrs }
- // regex: (source) (arrow) (target) (attrs?)
- const edgeMatch = line.match(/^(.+?)\s*(->|-->|\.\.>|==>)\s*(.+?)(\s*\{.*\})?$/);
- if (edgeMatch) {
- // Note: We intentionally catch lines starting with '[' here if they have an arrow.
- // This handles cases where AI mistakenly writes "[type] Node -> Node".
-
- const [, sourceRaw, arrow, targetRaw, attrsRaw] = edgeMatch;
-
- // Helper to clean potential [type] prefixes from IDs in edges
- const cleanId = (raw: string) => {
- const typeMatch = raw.match(/^\[.*?\]\s*(.+)$/);
- return (typeMatch ? typeMatch[1] : raw).trim();
- };
-
- // Extract labels/IDs from potential piped text: Source ->|Label| Target
- // Re-parsing source/target for piped labels if valid arrow syntax
- // "A ->|yes| B"
- const source = cleanId(sourceRaw.trim());
- let targetRawTrimmed = targetRaw.trim();
- let label = '';
-
- const pipeMatch = targetRawTrimmed.match(/^\|([^|]+)\|\s*(.+)$/);
- if (pipeMatch) {
- label = pipeMatch[1];
- targetRawTrimmed = pipeMatch[2].trim();
- }
- const target = cleanId(targetRawTrimmed);
-
- // Attributes
- const attributes = parseAttributes(attrsRaw || '');
-
- // Arrow styling
- if (arrow === '-->') attributes.styleType = 'curved';
- if (arrow === '..>') attributes.styleType = 'dashed';
- if (arrow === '==>') attributes.styleType = 'thick';
-
- dslEdges.push({
- sourceId: source, // Resolved later
- targetId: target, // Resolved later
- label,
- attributes
- });
- return;
- }
+ // 3. Group End: }
+ if (line === '}') {
+ if (currentGroupStack.length > 0) {
+ currentGroupStack.pop();
+ } else {
+ errors.push(`Line ${lineIndex + 1}: Unexpected '}'`);
+ }
+ return;
+ }
- // 5. Nodes: [type] id: Label { attrs }
- const nodeMatch = line.match(/^\[([a-zA-Z0-9_]+)\]\s*(?:([a-zA-Z0-9_]+):\s*)?([^{]+)(\s*\{.*\})?$/);
- if (nodeMatch) {
- const [, typeRaw, idRaw, labelRaw, attrsRaw] = nodeMatch;
- const type = NODE_TYPE_MAP[typeRaw.toLowerCase()] || 'process';
- const label = labelRaw.trim();
- const id = idRaw ? idRaw.trim() : label; // If no explicit ID, use label (backward compact)
-
- const attributes = parseAttributes(attrsRaw || '');
-
- const node: DSLNode = {
- id,
- type,
- label,
- attributes,
- parentId: undefined
- };
-
- dslNodes.push(node);
- labelToIdMap.set(label, id); // Map label to ID for edge resolution
- labelToIdMap.set(id, id); // Map ID to ID
- return;
- }
+ // 4. Edges: A -> B { attrs }
+ // regex: (source) (arrow) (target) (attrs?)
+ const edgeMatch = line.match(/^(.+?)\s*(->|-->|\.\.>|==>)\s*(.+?)(\s*\{.*\})?$/);
+ if (edgeMatch) {
+ // Note: We intentionally catch lines starting with '[' here if they have an arrow.
+ // This handles cases where AI mistakenly writes "[type] Node -> Node".
+
+ const [, sourceRaw, arrow, targetRaw, attrsRaw] = edgeMatch;
+
+ // Helper to clean potential [type] prefixes from IDs in edges
+ const cleanId = (raw: string) => {
+ const typeMatch = raw.match(/^\[.*?\]\s*(.+)$/);
+ return (typeMatch ? typeMatch[1] : raw).trim();
+ };
+
+ // Extract labels/IDs from potential piped text: Source ->|Label| Target
+ // Re-parsing source/target for piped labels if valid arrow syntax
+ // "A ->|yes| B"
+ const source = cleanId(sourceRaw.trim());
+ let targetRawTrimmed = targetRaw.trim();
+ let label = '';
+
+ const pipeMatch = targetRawTrimmed.match(/^\|([^|]+)\|\s*(.+)$/);
+ if (pipeMatch) {
+ label = pipeMatch[1];
+ targetRawTrimmed = pipeMatch[2].trim();
+ }
+ const target = cleanId(targetRawTrimmed);
+
+ // Attributes
+ const attributes = parseAttributes(attrsRaw || '');
+
+ // Arrow styling
+ if (arrow === '-->') attributes.styleType = 'curved';
+ if (arrow === '..>') attributes.styleType = 'dashed';
+ if (arrow === '==>') attributes.styleType = 'thick';
+
+ dslEdges.push({
+ sourceId: source, // Resolved later
+ targetId: target, // Resolved later
+ label,
+ attributes,
+ });
+ return;
+ }
- errors.push(`Line ${lineIndex + 1}: Unrecognized syntax "${line}"`);
- });
+ // 5. Nodes: [type] id: Label { attrs }
+ const nodeMatch = line.match(
+ /^\[([a-zA-Z0-9_]+)\]\s*(?:([a-zA-Z0-9_]+):\s*)?([^{]+)(\s*\{.*\})?$/
+ );
+ if (nodeMatch) {
+ const [, typeRaw, idRaw, labelRaw, attrsRaw] = nodeMatch;
+ const type = NODE_TYPE_MAP[typeRaw.toLowerCase()] || 'process';
+ const label = labelRaw.trim();
+ const id = idRaw ? idRaw.trim() : label; // If no explicit ID, use label (backward compact)
+
+ const attributes = parseAttributes(attrsRaw || '');
+
+ const node: DSLNode = {
+ id,
+ type,
+ label,
+ attributes,
+ };
+
+ dslNodes.push(node);
+ labelToIdMap.set(label, id); // Map label to ID for edge resolution
+ labelToIdMap.set(id, id); // Map ID to ID
+ return;
+ }
- if (currentGroupStack.length > 0) {
- errors.push(`Line ${lines.length}: Unclosed group block (missing closing "}")`);
+ errors.push(`Line ${lineIndex + 1}: Unrecognized syntax "${line}"`);
+ });
+
+ if (currentGroupStack.length > 0) {
+ errors.push(`Line ${lines.length}: Unclosed group block (missing closing "}")`);
+ }
+
+ // Post-processing: Resolve implicit nodes and edge IDs
+ const finalNodes: FlowNode[] = [];
+ const finalEdges: FlowEdge[] = [];
+ const createdNodeIds = new Set();
+
+ // 1. Process explicit nodes
+ dslNodes.forEach((n) => {
+ const defaultStyle = NODE_DEFAULTS[n.type] || NODE_DEFAULTS['process'];
+
+ // Layout placeholder (will be handled by ELK layout)
+ let node: FlowNode = {
+ id: n.id,
+ type: n.type,
+ position: { x: 0, y: 0 },
+ data: {
+ label: n.label,
+ shape: defaultStyle?.shape as NodeData['shape'],
+ color: defaultStyle?.color,
+ icon: defaultStyle?.icon && defaultStyle.icon !== 'none' ? defaultStyle.icon : undefined,
+ ...n.attributes,
+ ...(n.attributes.archProvider
+ ? { archIconPackId: resolveArchPackId(String(n.attributes.archProvider)) }
+ : {}),
+ ...(n.attributes.archResourceType
+ ? { archIconShapeId: String(n.attributes.archResourceType) }
+ : {}),
+ ...(n.attributes.archResourceType ? { assetPresentation: 'icon' as const } : {}),
+ },
+ };
+ if (n.parentId) {
+ node = setNodeParent(node, n.parentId, {
+ constrainToParent: false,
+ });
}
+ finalNodes.push(node);
+ createdNodeIds.add(n.id);
+ });
+
+ // 2. Process edges and create implicit nodes
+ dslEdges.forEach((e, i) => {
+ const sourceId = labelToIdMap.get(e.sourceId) || e.sourceId;
+ const targetId = labelToIdMap.get(e.targetId) || e.targetId;
+
+ const ensureNode = (nodeId: string) => {
+ if (createdNodeIds.has(nodeId)) return;
+ const style = NODE_DEFAULTS['process'];
+ finalNodes.push({
+ id: nodeId,
+ type: 'process',
+ position: { x: 0, y: 0 },
+ data: {
+ label: nodeId,
+ shape: style?.shape as NodeData['shape'],
+ color: style?.color,
+ icon: style?.icon && style.icon !== 'none' ? style.icon : undefined,
+ },
+ });
+ createdNodeIds.add(nodeId);
+ labelToIdMap.set(nodeId, nodeId);
+ };
- // Post-processing: Resolve implicit nodes and edge IDs
- const finalNodes: FlowNode[] = [];
- const finalEdges: FlowEdge[] = [];
- const createdNodeIds = new Set();
-
- // 1. Process explicit nodes
- dslNodes.forEach((n) => {
- const defaultStyle = NODE_DEFAULTS[n.type] || NODE_DEFAULTS['process'];
-
- // Layout placeholder (will be handled by ELK layout)
- let node: FlowNode = {
- id: n.id,
- type: n.type,
- position: { x: 0, y: 0 },
- data: {
- label: n.label,
- shape: defaultStyle?.shape as NodeData['shape'],
- color: defaultStyle?.color,
- icon: defaultStyle?.icon && defaultStyle.icon !== 'none' ? defaultStyle.icon : undefined,
- ...n.attributes
- },
- };
- if (n.parentId) {
- node = setNodeParent(node, n.parentId);
- }
- finalNodes.push(node);
- createdNodeIds.add(n.id);
- });
-
- // 2. Process edges and create implicit nodes
- dslEdges.forEach((e, i) => {
- const sourceId = labelToIdMap.get(e.sourceId) || e.sourceId;
- const targetId = labelToIdMap.get(e.targetId) || e.targetId;
-
- // If nodes parse as "A -> B" and A wasn't defined, create a default process node
- const defaultProcessStyle = NODE_DEFAULTS['process'];
-
- if (!createdNodeIds.has(sourceId)) {
- finalNodes.push({
- id: sourceId,
- type: 'process',
- position: { x: 0, y: 0 },
- data: {
- label: sourceId,
- shape: defaultProcessStyle?.shape as NodeData['shape'],
- color: defaultProcessStyle?.color,
- icon: defaultProcessStyle?.icon && defaultProcessStyle.icon !== 'none' ? defaultProcessStyle.icon : undefined,
- }
- });
- createdNodeIds.add(sourceId);
- labelToIdMap.set(sourceId, sourceId);
- }
- if (!createdNodeIds.has(targetId)) {
- finalNodes.push({
- id: targetId,
- type: 'process',
- position: { x: 0, y: 0 },
- data: {
- label: targetId,
- shape: defaultProcessStyle?.shape as NodeData['shape'],
- color: defaultProcessStyle?.color,
- icon: defaultProcessStyle?.icon && defaultProcessStyle.icon !== 'none' ? defaultProcessStyle.icon : undefined,
- }
- });
- createdNodeIds.add(targetId);
- labelToIdMap.set(targetId, targetId);
- }
+ ensureNode(sourceId);
+ ensureNode(targetId);
- const finalEdge: FlowEdge = {
- id: `edge-${i}`, // Unique ID for the edge
- source: sourceId,
- target: targetId,
- label: e.label,
- type: 'default', // Default edge type
- data: { label: e.label }
- };
-
- // Merge attributes into edge data or style
- if (Object.keys(e.attributes).length > 0) {
- finalEdge.data = { ...finalEdge.data, ...e.attributes };
-
- // Map 'style' attribute to styleType for convenience/tests
- const styleType = e.attributes.styleType || e.attributes.style;
-
- // Handle specific style mappings if needed
- if (styleType === 'curved') {
- finalEdge.type = 'smoothstep';
- finalEdge.data.styleType = 'curved';
- } else if (styleType === 'dashed') {
- finalEdge.style = { strokeDasharray: '5 5' };
- finalEdge.data.styleType = 'dashed';
- } else if (styleType === 'thick') {
- finalEdge.style = { strokeWidth: 3 };
- finalEdge.data.styleType = 'thick';
- }
- }
- finalEdges.push(finalEdge);
- });
-
- return {
- nodes: finalNodes,
- edges: finalEdges,
- metadata,
- errors
+ const finalEdge: FlowEdge = {
+ id: `edge-${i}`, // Unique ID for the edge
+ source: sourceId,
+ target: targetId,
+ label: e.label,
+ type: 'default', // Default edge type
+ data: { label: e.label },
};
-};
+
+ // Merge attributes into edge data or style
+ if (Object.keys(e.attributes).length > 0) {
+ finalEdge.data = { ...finalEdge.data, ...e.attributes };
+
+ // Map 'style' attribute to styleType for convenience/tests
+ const styleType = e.attributes.styleType || e.attributes.style;
+
+ // Handle specific style mappings if needed
+ if (styleType === 'curved') {
+ finalEdge.type = 'smoothstep';
+ finalEdge.data.styleType = 'curved';
+ } else if (styleType === 'dashed') {
+ finalEdge.style = { strokeDasharray: '5 5' };
+ finalEdge.data.styleType = 'dashed';
+ } else if (styleType === 'thick') {
+ finalEdge.style = { strokeWidth: 3 };
+ finalEdge.data.styleType = 'thick';
+ }
+ }
+ finalEdges.push(finalEdge);
+ });
+
+ return {
+ nodes: finalNodes,
+ edges: finalEdges,
+ metadata,
+ errors,
+ };
+}
export const parseFlowMindDSL = parseOpenFlowDslV2;
diff --git a/src/lib/iconMatcher.test.ts b/src/lib/iconMatcher.test.ts
new file mode 100644
index 00000000..0edb1d42
--- /dev/null
+++ b/src/lib/iconMatcher.test.ts
@@ -0,0 +1,112 @@
+import { describe, expect, it } from 'vitest';
+import { matchIcon, getMatchableIconCount, listIconProviders, buildCatalogSummary } from './iconMatcher';
+
+describe('iconMatcher', () => {
+ it('finds icons from the catalog', () => {
+ const count = getMatchableIconCount();
+ expect(count).toBeGreaterThan(100);
+ });
+
+ it('lists available providers', () => {
+ const providers = listIconProviders();
+ expect(providers).toContain('developer');
+ expect(providers).toContain('aws');
+ });
+
+ it('exact match: postgresql finds the PostgreSQL icon', () => {
+ const results = matchIcon('postgresql');
+ expect(results.length).toBeGreaterThan(0);
+ expect(results[0].shapeId).toContain('postgresql');
+ expect(results[0].matchType).toBe('exact');
+ expect(results[0].score).toBeGreaterThan(0.9);
+ });
+
+ it('alias match: "postgres" resolves to postgresql', () => {
+ const results = matchIcon('postgres');
+ expect(results.length).toBeGreaterThan(0);
+ expect(results[0].shapeId).toContain('postgresql');
+ expect(results[0].matchType).toBe('alias');
+ });
+
+ it('alias match: "k8s" resolves to kubernetes', () => {
+ const results = matchIcon('k8s');
+ expect(results.length).toBeGreaterThan(0);
+ expect(results[0].shapeId).toContain('kubernetes');
+ expect(results[0].matchType).toBe('alias');
+ });
+
+ it('substring match: "redis" finds redis icons', () => {
+ const results = matchIcon('redis');
+ expect(results.length).toBeGreaterThan(0);
+ expect(results[0].shapeId).toContain('redis');
+ });
+
+ it('surfaces richer ranking metadata for trusted matches', () => {
+ const results = matchIcon('react');
+ expect(results[0]?.confidence).toBeTruthy();
+ expect(typeof results[0]?.reason).toBe('string');
+ expect(typeof results[0]?.runnerUpDelta).toBe('number');
+ expect(typeof results[0]?.wholeTokenMatch).toBe('boolean');
+ });
+
+ it('prefers canonical icons over wordmark or light-dark variants', () => {
+ const results = matchIcon('nextjs');
+ expect(results.length).toBeGreaterThan(0);
+ expect(results[0]?.isVariant).toBe(false);
+ });
+
+ it('marks generic matches as generic', () => {
+ const results = matchIcon('service');
+ if (results.length > 0) {
+ expect(results[0]?.isGeneric).toBe(true);
+ }
+ });
+
+ it('provider filter: "lambda" with provider "aws" finds AWS Lambda', () => {
+ const results = matchIcon('lambda', 'aws');
+ expect(results.length).toBeGreaterThan(0);
+ expect(results[0].provider).toBe('aws');
+ expect(results[0].shapeId).toContain('lambda');
+ });
+
+ it('provider filter: "lambda" without filter finds any provider', () => {
+ const results = matchIcon('lambda');
+ expect(results.length).toBeGreaterThan(0);
+ });
+
+ it('returns empty for unknown queries', () => {
+ const results = matchIcon('zzzznotreal99999');
+ expect(results).toEqual([]);
+ });
+
+ it('returns empty for empty query', () => {
+ expect(matchIcon('')).toEqual([]);
+ expect(matchIcon(' ')).toEqual([]);
+ });
+
+ it('matchType classification works', () => {
+ const exact = matchIcon('docker');
+ if (exact.length > 0) {
+ expect(['exact', 'alias', 'substring']).toContain(exact[0].matchType);
+ }
+ });
+
+ it('lists all expected providers', () => {
+ const providers = listIconProviders();
+ expect(providers).toEqual(expect.arrayContaining(['aws', 'azure', 'cncf', 'developer', 'gcp']));
+ expect(providers).toHaveLength(5);
+ });
+
+ it('buildCatalogSummary returns non-empty summary with provider names', () => {
+ const summary = buildCatalogSummary(5);
+ expect(summary.length).toBeGreaterThan(0);
+ expect(summary).toContain('aws');
+ expect(summary).toContain('developer');
+ });
+
+ it('buildCatalogSummary respects maxPerProvider limit', () => {
+ const small = buildCatalogSummary(2);
+ const large = buildCatalogSummary(50);
+ expect(small.length).toBeLessThan(large.length);
+ });
+});
diff --git a/src/lib/iconMatcher.ts b/src/lib/iconMatcher.ts
new file mode 100644
index 00000000..55b240be
--- /dev/null
+++ b/src/lib/iconMatcher.ts
@@ -0,0 +1,439 @@
+import { SVG_SOURCES } from '@/services/shapeLibrary/providerCatalog';
+
+export interface IconMatch {
+ packId: string;
+ shapeId: string;
+ label: string;
+ provider: string;
+ category: string;
+ score: number;
+ matchType: 'exact' | 'alias' | 'substring' | 'category';
+ confidence: 'high' | 'medium' | 'low';
+ reason: string;
+ runnerUpDelta: number;
+ wholeTokenMatch: boolean;
+ isVariant: boolean;
+ isGeneric: boolean;
+}
+
+const ALIASES: Record = {
+ postgres: 'postgresql',
+ pg: 'postgresql',
+ pgsql: 'postgresql',
+ mongo: 'mongodb',
+ mdb: 'mongodb',
+ es: 'elasticsearch',
+ elastic: 'elasticsearch',
+ k8s: 'kubernetes',
+ tf: 'terraform',
+ hcl: 'terraform',
+ golang: 'go',
+ js: 'javascript',
+ ts: 'typescript',
+ py: 'python',
+ rb: 'ruby',
+ njs: 'nodejs',
+ node: 'nodejs',
+ react: 'frontend-reactjs',
+ 'react.js': 'react',
+ 'vue.js': 'vue',
+ next: 'nextjs',
+ 'nuxt.js': 'nuxt',
+ mq: 'rabbitmq',
+ apachekafka: 'kafka',
+ csharp: 'c#',
+ dotnet: '.net',
+ gke: 'google-kubernetes-engine',
+ aks: 'azure-kubernetes-service',
+ eks: 'amazon-elastic-kubernetes-service',
+ rds: 'amazon-rds',
+ sqs: 'app-integration-simple-queue-service',
+ sns: 'app-integration-simple-notification-service',
+ s3: 'storage-simple-storage-service',
+ 'amazon-s3': 'storage-simple-storage-service',
+ lambda: 'compute-lambda',
+ 'aws-lambda': 'compute-lambda',
+ cf: 'cloudflare',
+ kib: 'kibana',
+ logstash: 'elastic-logstash',
+ beat: 'elastic-beats',
+};
+
+const VARIANT_TOKENS = new Set(['wordmark', 'light', 'dark', 'logo', 'mark', 'filled', 'outline']);
+const GENERIC_ENTRY_TOKENS = new Set([
+ 'api',
+ 'app',
+ 'apps',
+ 'auth',
+ 'backend',
+ 'browser',
+ 'cache',
+ 'cdn',
+ 'client',
+ 'cloud',
+ 'compute',
+ 'container',
+ 'containers',
+ 'database',
+ 'databases',
+ 'delivery',
+ 'end',
+ 'frontend',
+ 'gateway',
+ 'identity',
+ 'integration',
+ 'mobile',
+ 'network',
+ 'networking',
+ 'process',
+ 'project',
+ 'projects',
+ 'proxy',
+ 'queue',
+ 'security',
+ 'server',
+ 'service',
+ 'services',
+ 'simple',
+ 'storage',
+ 'system',
+ 'tool',
+ 'tools',
+ 'user',
+ 'users',
+ 'web',
+ 'worker',
+]);
+
+function normalize(text: string): string {
+ return text
+ .toLowerCase()
+ .replace(/[\s._]+/g, '-')
+ .replace(/[^a-z0-9-]/g, '')
+ .replace(/-+/g, '-')
+ .replace(/^-|-$/g, '');
+}
+
+function tokenizeNormalized(text: string): string[] {
+ return normalize(text)
+ .split('-')
+ .filter(Boolean);
+}
+
+function hasTokenSequence(haystack: string[], needle: string[]): boolean {
+ if (needle.length === 0 || haystack.length < needle.length) {
+ return false;
+ }
+
+ for (let start = 0; start <= haystack.length - needle.length; start += 1) {
+ let matched = true;
+ for (let index = 0; index < needle.length; index += 1) {
+ if (haystack[start + index] !== needle[index]) {
+ matched = false;
+ break;
+ }
+ }
+ if (matched) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+function tokensRoughlyMatch(queryToken: string, entryToken: string): boolean {
+ if (queryToken === entryToken) {
+ return true;
+ }
+
+ if (queryToken.length < 4 || entryToken.length < 4) {
+ return false;
+ }
+
+ return entryToken.startsWith(queryToken) || queryToken.startsWith(entryToken);
+}
+
+function clampScore(value: number): number {
+ return Math.max(0, Math.min(0.99, value));
+}
+
+function toConfidence(score: number): IconMatch['confidence'] {
+ if (score >= 0.9) {
+ return 'high';
+ }
+ if (score >= 0.75) {
+ return 'medium';
+ }
+ return 'low';
+}
+
+function compareMatches(left: IconMatch, right: IconMatch): number {
+ if (right.score !== left.score) {
+ return right.score - left.score;
+ }
+ if (left.isGeneric !== right.isGeneric) {
+ return Number(left.isGeneric) - Number(right.isGeneric);
+ }
+ if (left.isVariant !== right.isVariant) {
+ return Number(left.isVariant) - Number(right.isVariant);
+ }
+ if (left.wholeTokenMatch !== right.wholeTokenMatch) {
+ return Number(right.wholeTokenMatch) - Number(left.wholeTokenMatch);
+ }
+ return left.label.localeCompare(right.label);
+}
+
+function getRunnerUpDelta(matches: IconMatch[], index: number): number {
+ const nextScore = matches[index + 1]?.score ?? 0;
+ return Math.max(0, matches[index].score - nextScore);
+}
+
+function entries(): IconEntry[] {
+ return SVG_SOURCES.map((s) => {
+ const parts = s.shapeId.split('/');
+ const lastPathPart = parts[parts.length - 1];
+ const lastHyphenPart = lastPathPart.split('-').pop() ?? lastPathPart;
+ const normalizedName = normalize(s.shapeId);
+ const normalizedLabel = normalize(s.label);
+ const nameTokens = tokenizeNormalized(s.shapeId);
+ const labelTokens = tokenizeNormalized(s.label);
+ const lastSegmentTokens = tokenizeNormalized(lastHyphenPart);
+ const meaningfulTokens = lastSegmentTokens.filter((token) => !VARIANT_TOKENS.has(token));
+ return {
+ packId: s.packId,
+ shapeId: s.shapeId,
+ label: s.label,
+ provider: s.provider,
+ category: s.category,
+ normalizedName,
+ normalizedLabel,
+ normalizedLastSegment: normalize(lastHyphenPart),
+ nameTokens,
+ labelTokens,
+ lastSegmentTokens,
+ isVariant: lastSegmentTokens.some((token) => VARIANT_TOKENS.has(token)),
+ isGeneric:
+ meaningfulTokens.length > 0
+ && meaningfulTokens.every((token) => GENERIC_ENTRY_TOKENS.has(token)),
+ };
+ });
+}
+
+interface IconEntry {
+ packId: string;
+ shapeId: string;
+ label: string;
+ provider: string;
+ category: string;
+ normalizedName: string;
+ normalizedLabel: string;
+ normalizedLastSegment: string;
+ nameTokens: string[];
+ labelTokens: string[];
+ lastSegmentTokens: string[];
+ isVariant: boolean;
+ isGeneric: boolean;
+}
+
+let cachedEntries: IconEntry[] | null = null;
+function getEntries(): IconEntry[] {
+ if (!cachedEntries) cachedEntries = entries();
+ return cachedEntries;
+}
+
+let cachedByNormalized: Map | null = null;
+function getByNormalized(): Map {
+ if (!cachedByNormalized) {
+ cachedByNormalized = new Map();
+ for (const entry of getEntries()) {
+ cachedByNormalized.set(entry.normalizedName, entry);
+ if (entry.normalizedLastSegment !== entry.normalizedName) {
+ cachedByNormalized.set(entry.normalizedLastSegment, entry);
+ }
+ }
+ }
+ return cachedByNormalized;
+}
+
+export function matchIcon(query: string, providerHint?: string): IconMatch[] {
+ const normalizedQuery = normalize(query);
+ if (!normalizedQuery) return [];
+ const queryTokens = tokenizeNormalized(query);
+
+ const byNormalized = getByNormalized();
+ const all = getEntries();
+
+ // 1. Exact match on shape ID or human label
+ const exact = byNormalized.get(normalizedQuery);
+ if (exact && (!providerHint || exact.provider === providerHint)) {
+ return finalizeMatches([toMatch(exact, 0.99, 'exact', 'exact shape or segment match', true)]);
+ }
+
+ const exactLabel = all.find((entry) => {
+ if (providerHint && entry.provider !== providerHint) {
+ return false;
+ }
+ return (
+ entry.normalizedLabel === normalizedQuery || entry.normalizedLastSegment === normalizedQuery
+ );
+ });
+ if (exactLabel) {
+ return finalizeMatches([toMatch(exactLabel, 0.98, 'exact', 'exact icon label match', true)]);
+ }
+
+ // 2. Alias resolution
+ const aliasTarget = ALIASES[normalizedQuery];
+ if (aliasTarget) {
+ const aliasEntry = byNormalized.get(normalize(aliasTarget));
+ if (aliasEntry && (!providerHint || aliasEntry.provider === providerHint)) {
+ return finalizeMatches([toMatch(aliasEntry, 0.97, 'alias', 'known technology alias', true)]);
+ }
+ }
+
+ // 3. Weighted token and label matching
+ const substringMatches: IconMatch[] = [];
+ for (const entry of all) {
+ if (providerHint && entry.provider !== providerHint) continue;
+ if (
+ entry.normalizedLastSegment.length < 3
+ || normalizedQuery.length < 3
+ || queryTokens.length === 0
+ ) {
+ continue;
+ }
+
+ const tokenHits = queryTokens.filter(
+ (token) =>
+ entry.nameTokens.some((entryToken) => tokensRoughlyMatch(token, entryToken))
+ || entry.labelTokens.some((entryToken) => tokensRoughlyMatch(token, entryToken))
+ || entry.lastSegmentTokens.some((entryToken) => tokensRoughlyMatch(token, entryToken))
+ );
+ const wholeTokenMatch = tokenHits.length === queryTokens.length;
+ const hasPartialMatch =
+ entry.normalizedName.includes(normalizedQuery)
+ || entry.normalizedLabel.includes(normalizedQuery)
+ || entry.normalizedLastSegment.includes(normalizedQuery)
+ || normalizedQuery.includes(entry.normalizedLastSegment);
+
+ if (!wholeTokenMatch && !hasPartialMatch) {
+ continue;
+ }
+
+ const exactLastSegment = entry.normalizedLastSegment === normalizedQuery;
+ const exactLabelMatch = entry.normalizedLabel === normalizedQuery;
+ const nameSequenceMatch = hasTokenSequence(entry.nameTokens, queryTokens);
+ const labelSequenceMatch = hasTokenSequence(entry.labelTokens, queryTokens);
+ const overlapRatio = tokenHits.length / queryTokens.length;
+ let score =
+ 0.42
+ + overlapRatio * 0.28
+ + (exactLastSegment ? 0.14 : 0)
+ + (exactLabelMatch ? 0.12 : 0)
+ + (wholeTokenMatch ? 0.08 : 0)
+ + (nameSequenceMatch || labelSequenceMatch ? 0.06 : 0)
+ + (providerHint && entry.provider === providerHint ? 0.04 : 0)
+ - (entry.isVariant ? 0.18 : 0)
+ - (entry.isGeneric ? 0.16 : 0);
+
+ if (!wholeTokenMatch && hasPartialMatch) {
+ score -= 0.08;
+ }
+
+ score = clampScore(score);
+ const reason = exactLastSegment
+ ? 'exact canonical icon segment'
+ : exactLabelMatch
+ ? 'exact icon label match'
+ : wholeTokenMatch
+ ? 'all query tokens align to icon tokens'
+ : 'partial token overlap';
+
+ substringMatches.push(toMatch(entry, score, 'substring', reason, wholeTokenMatch));
+ }
+ if (substringMatches.length > 0) {
+ return finalizeMatches(substringMatches);
+ }
+
+ // 4. Category match
+ const normalizedCategory = normalizedQuery.replace(/-/g, '');
+ const categoryMatches: IconMatch[] = [];
+ for (const entry of all) {
+ if (providerHint && entry.provider !== providerHint) continue;
+ if (normalize(entry.category).replace(/-/g, '').includes(normalizedCategory)) {
+ categoryMatches.push(
+ toMatch(
+ entry,
+ clampScore(0.54 - (entry.isVariant ? 0.08 : 0) - (entry.isGeneric ? 0.1 : 0)),
+ 'category',
+ 'category-only fallback',
+ false
+ )
+ );
+ }
+ }
+ if (categoryMatches.length > 0) {
+ return finalizeMatches(categoryMatches);
+ }
+
+ return [];
+}
+
+function finalizeMatches(matches: IconMatch[]): IconMatch[] {
+ const sorted = [...matches].sort(compareMatches);
+
+ return sorted.slice(0, 5).map((match, index, topMatches) => ({
+ ...match,
+ confidence: toConfidence(match.score),
+ runnerUpDelta: getRunnerUpDelta(topMatches, index),
+ }));
+}
+
+function toMatch(
+ entry: IconEntry,
+ score: number,
+ matchType: IconMatch['matchType'],
+ reason: string,
+ wholeTokenMatch: boolean
+): IconMatch {
+ return {
+ packId: entry.packId,
+ shapeId: entry.shapeId,
+ label: entry.label,
+ provider: entry.provider,
+ category: entry.category,
+ score,
+ matchType,
+ confidence: toConfidence(score),
+ reason,
+ runnerUpDelta: 0,
+ wholeTokenMatch,
+ isVariant: entry.isVariant,
+ isGeneric: entry.isGeneric,
+ };
+}
+
+export function getMatchableIconCount(): number {
+ return getEntries().length;
+}
+
+export function listIconProviders(): string[] {
+ return [...new Set(getEntries().map((e) => e.provider))].sort();
+}
+
+export function buildCatalogSummary(maxPerProvider: number = 30): string {
+ const byProvider = new Map();
+ for (const entry of getEntries()) {
+ const list = byProvider.get(entry.provider) ?? [];
+ list.push(entry);
+ byProvider.set(entry.provider, list);
+ }
+
+ const lines: string[] = [];
+ for (const [provider, icons] of byProvider) {
+ const categories = [...new Set(icons.map((i) => i.category))];
+ const sampleNames = icons.slice(0, maxPerProvider).map((i) => i.label);
+ lines.push(`${provider}: ${categories.join(', ')} (examples: ${sampleNames.join(', ')})`);
+ }
+
+ return lines.join('\n');
+}
diff --git a/src/lib/iconResolver.test.ts b/src/lib/iconResolver.test.ts
new file mode 100644
index 00000000..61d13fe5
--- /dev/null
+++ b/src/lib/iconResolver.test.ts
@@ -0,0 +1,75 @@
+import { describe, expect, it } from 'vitest';
+import { resolveIconSync, resolveLucideFallback } from './iconResolver';
+
+describe('resolveIconSync', () => {
+ it('resolves PostgreSQL alias', () => {
+ const result = resolveIconSync('PostgreSQL');
+ expect(result.found).toBe(true);
+ expect(result.iconSearch).toBe('postgresql');
+ expect(result.catalog).toBe('developer');
+ expect(result.confidence).toBeGreaterThan(0.9);
+ });
+
+ it('resolves shorthand aliases', () => {
+ expect(resolveIconSync('postgres').iconSearch).toBe('postgresql');
+ expect(resolveIconSync('pg').iconSearch).toBe('postgresql');
+ expect(resolveIconSync('mongo').iconSearch).toBe('mongodb');
+ expect(resolveIconSync('k8s').iconSearch).toBe('kubernetes');
+ });
+
+ it('resolves framework aliases', () => {
+ expect(resolveIconSync('React').catalog).toBe('developer');
+ expect(resolveIconSync('Next.js').iconSearch).toBe('nextjs');
+ expect(resolveIconSync('Express').iconSearch).toBe('express');
+ expect(resolveIconSync('Django').iconSearch).toBe('django');
+ expect(resolveIconSync('FastAPI').iconSearch).toBe('fastapi');
+ });
+
+ it('resolves infrastructure aliases', () => {
+ expect(resolveIconSync('Docker').catalog).toBe('developer');
+ expect(resolveIconSync('Kubernetes').catalog).toBe('cncf');
+ expect(resolveIconSync('nginx').iconSearch).toBe('nginx');
+ expect(resolveIconSync('RabbitMQ').iconSearch).toBe('rabbitmq');
+ expect(resolveIconSync('Kafka').iconSearch).toBe('apachekafka');
+ });
+
+ it('resolves cloud service aliases', () => {
+ expect(resolveIconSync('S3').catalog).toBe('aws');
+ expect(resolveIconSync('Lambda').catalog).toBe('aws');
+ expect(resolveIconSync('Cloud Run').catalog).toBe('gcp');
+ expect(resolveIconSync('Azure Functions').catalog).toBe('azure');
+ });
+
+ it('returns not found for unknown queries', () => {
+ const result = resolveIconSync('RandomThing123');
+ expect(result.found).toBe(false);
+ expect(result.confidence).toBe(0);
+ });
+
+ it('uses category fallback when alias not found', () => {
+ const result = resolveIconSync('MyCustomDB', 'database');
+ expect(result.found).toBe(true);
+ expect(result.lucideIcon).toBe('database');
+ expect(result.confidence).toBe(0.5);
+ });
+
+ it('handles empty query', () => {
+ expect(resolveIconSync('').found).toBe(false);
+ expect(resolveIconSync(' ').found).toBe(false);
+ });
+});
+
+describe('resolveLucideFallback', () => {
+ it('returns correct fallback icons', () => {
+ expect(resolveLucideFallback('database')).toBe('database');
+ expect(resolveLucideFallback('cache')).toBe('hard-drive');
+ expect(resolveLucideFallback('service')).toBe('server');
+ expect(resolveLucideFallback('frontend')).toBe('monitor');
+ expect(resolveLucideFallback('user')).toBe('user');
+ expect(resolveLucideFallback('gateway')).toBe('shield');
+ });
+
+ it('returns box for unknown categories', () => {
+ expect(resolveLucideFallback('unknown')).toBe('box');
+ });
+});
diff --git a/src/lib/iconResolver.ts b/src/lib/iconResolver.ts
new file mode 100644
index 00000000..35fb684f
--- /dev/null
+++ b/src/lib/iconResolver.ts
@@ -0,0 +1,425 @@
+import type { DomainLibraryCategory } from '@/services/domainLibrary';
+
+export interface IconResolution {
+ found: boolean;
+ archIconPackId?: string;
+ archIconShapeId?: string;
+ iconSearch?: string;
+ catalog?: DomainLibraryCategory;
+ lucideIcon?: string;
+ label?: string;
+ category?: string;
+ confidence: number;
+}
+
+interface AliasEntry {
+ patterns: RegExp[];
+ iconSearch: string;
+ catalog: DomainLibraryCategory;
+ lucideFallback: string;
+}
+
+const ALIAS_TABLE: AliasEntry[] = [
+ // Databases
+ {
+ patterns: [/^postgres(?:ql)?$/i, /^pg$/i],
+ iconSearch: 'postgresql',
+ catalog: 'developer',
+ lucideFallback: 'database',
+ },
+ { patterns: [/^mysql$/i], iconSearch: 'mysql', catalog: 'developer', lucideFallback: 'database' },
+ {
+ patterns: [/^mongo(?:db)?$/i],
+ iconSearch: 'mongodb',
+ catalog: 'developer',
+ lucideFallback: 'database',
+ },
+ {
+ patterns: [/^redis$/i],
+ iconSearch: 'redis',
+ catalog: 'developer',
+ lucideFallback: 'hard-drive',
+ },
+ {
+ patterns: [/^elastic(?:search)?$/i],
+ iconSearch: 'elasticsearch',
+ catalog: 'developer',
+ lucideFallback: 'search',
+ },
+ { patterns: [/^dynamodb$/i], iconSearch: 'dynamodb', catalog: 'aws', lucideFallback: 'database' },
+ { patterns: [/^aurora$/i], iconSearch: 'aurora', catalog: 'aws', lucideFallback: 'database' },
+ {
+ patterns: [/^sqlite$/i],
+ iconSearch: 'sqlite',
+ catalog: 'developer',
+ lucideFallback: 'database',
+ },
+ {
+ patterns: [/^mariadb$/i],
+ iconSearch: 'mariadb',
+ catalog: 'developer',
+ lucideFallback: 'database',
+ },
+ {
+ patterns: [/^cassandra$/i],
+ iconSearch: 'cassandra',
+ catalog: 'developer',
+ lucideFallback: 'database',
+ },
+ { patterns: [/^neo4j$/i], iconSearch: 'neo4j', catalog: 'developer', lucideFallback: 'database' },
+ {
+ patterns: [/^supabase$/i],
+ iconSearch: 'supabase',
+ catalog: 'developer',
+ lucideFallback: 'database',
+ },
+ {
+ patterns: [/^planetscale$/i],
+ iconSearch: 'planetscale',
+ catalog: 'developer',
+ lucideFallback: 'database',
+ },
+ { patterns: [/^neon\b/i], iconSearch: 'neon', catalog: 'developer', lucideFallback: 'database' },
+
+ // Frameworks
+ {
+ patterns: [/^express(?:\.?js)?$/i],
+ iconSearch: 'express',
+ catalog: 'developer',
+ lucideFallback: 'server',
+ },
+ {
+ patterns: [/^node(?:\.?js)?$/i],
+ iconSearch: 'nodejs',
+ catalog: 'developer',
+ lucideFallback: 'server',
+ },
+ {
+ patterns: [/^react(?:\.?js)?$/i],
+ iconSearch: 'react',
+ catalog: 'developer',
+ lucideFallback: 'monitor',
+ },
+ {
+ patterns: [/^vue(?:\.?js)?$/i],
+ iconSearch: 'vue',
+ catalog: 'developer',
+ lucideFallback: 'monitor',
+ },
+ {
+ patterns: [/^angular$/i],
+ iconSearch: 'angular',
+ catalog: 'developer',
+ lucideFallback: 'monitor',
+ },
+ {
+ patterns: [/^svelte$/i],
+ iconSearch: 'svelte',
+ catalog: 'developer',
+ lucideFallback: 'monitor',
+ },
+ {
+ patterns: [/^next(?:\.?js)?$/i],
+ iconSearch: 'nextjs',
+ catalog: 'developer',
+ lucideFallback: 'monitor',
+ },
+ { patterns: [/^nuxt$/i], iconSearch: 'nuxt', catalog: 'developer', lucideFallback: 'monitor' },
+ { patterns: [/^django$/i], iconSearch: 'django', catalog: 'developer', lucideFallback: 'server' },
+ { patterns: [/^flask$/i], iconSearch: 'flask', catalog: 'developer', lucideFallback: 'server' },
+ {
+ patterns: [/^fastapi$/i],
+ iconSearch: 'fastapi',
+ catalog: 'developer',
+ lucideFallback: 'server',
+ },
+ {
+ patterns: [/^spring(?:\s*boot)?$/i],
+ iconSearch: 'spring',
+ catalog: 'developer',
+ lucideFallback: 'server',
+ },
+ {
+ patterns: [/^rails$/i, /^ruby$/i],
+ iconSearch: 'rails',
+ catalog: 'developer',
+ lucideFallback: 'server',
+ },
+ {
+ patterns: [/^laravel$/i],
+ iconSearch: 'laravel',
+ catalog: 'developer',
+ lucideFallback: 'server',
+ },
+ {
+ patterns: [/^nest(?:\.?js)?$/i],
+ iconSearch: 'nestjs',
+ catalog: 'developer',
+ lucideFallback: 'server',
+ },
+ { patterns: [/^gin$/i], iconSearch: 'gin', catalog: 'developer', lucideFallback: 'server' },
+ {
+ patterns: [/^go$/i, /^golang$/i],
+ iconSearch: 'go',
+ catalog: 'developer',
+ lucideFallback: 'server',
+ },
+ { patterns: [/^rust$/i], iconSearch: 'rust', catalog: 'developer', lucideFallback: 'server' },
+ { patterns: [/^deno$/i], iconSearch: 'deno', catalog: 'developer', lucideFallback: 'server' },
+ { patterns: [/^bun$/i], iconSearch: 'bun', catalog: 'developer', lucideFallback: 'server' },
+
+ // Infrastructure
+ {
+ patterns: [/^docker$/i],
+ iconSearch: 'docker',
+ catalog: 'developer',
+ lucideFallback: 'container',
+ },
+ {
+ patterns: [/^kubernetes$/i, /^k8s$/i],
+ iconSearch: 'kubernetes',
+ catalog: 'cncf',
+ lucideFallback: 'container',
+ },
+ { patterns: [/^nginx$/i], iconSearch: 'nginx', catalog: 'developer', lucideFallback: 'shield' },
+ {
+ patterns: [/^rabbitmq$/i],
+ iconSearch: 'rabbitmq',
+ catalog: 'developer',
+ lucideFallback: 'layers',
+ },
+ {
+ patterns: [/^kafka$/i, /^apache\s*kafka$/i],
+ iconSearch: 'apachekafka',
+ catalog: 'developer',
+ lucideFallback: 'layers',
+ },
+ {
+ patterns: [/^consul$/i],
+ iconSearch: 'consul',
+ catalog: 'developer',
+ lucideFallback: 'map-pin',
+ },
+ { patterns: [/^vault$/i], iconSearch: 'vault', catalog: 'developer', lucideFallback: 'lock' },
+ {
+ patterns: [/^terraform$/i],
+ iconSearch: 'terraform',
+ catalog: 'developer',
+ lucideFallback: 'layers',
+ },
+ {
+ patterns: [/^ansible$/i],
+ iconSearch: 'ansible',
+ catalog: 'developer',
+ lucideFallback: 'settings',
+ },
+ {
+ patterns: [/^prometheus$/i],
+ iconSearch: 'prometheus',
+ catalog: 'developer',
+ lucideFallback: 'activity',
+ },
+ {
+ patterns: [/^grafana$/i],
+ iconSearch: 'grafana',
+ catalog: 'developer',
+ lucideFallback: 'bar-chart',
+ },
+ {
+ patterns: [/^jenkins$/i],
+ iconSearch: 'jenkins',
+ catalog: 'developer',
+ lucideFallback: 'settings',
+ },
+ {
+ patterns: [/^gitlab$/i],
+ iconSearch: 'gitlab',
+ catalog: 'developer',
+ lucideFallback: 'git-branch',
+ },
+ {
+ patterns: [/^github$/i],
+ iconSearch: 'github',
+ catalog: 'developer',
+ lucideFallback: 'git-branch',
+ },
+ { patterns: [/^helm$/i], iconSearch: 'helm', catalog: 'cncf', lucideFallback: 'package' },
+ { patterns: [/^istio$/i], iconSearch: 'istio', catalog: 'cncf', lucideFallback: 'network' },
+ { patterns: [/^envoy$/i], iconSearch: 'envoy', catalog: 'cncf', lucideFallback: 'network' },
+ {
+ patterns: [/^grafana\s*tempo$/i, /^tempo$/i],
+ iconSearch: 'grafana-tempo',
+ catalog: 'developer',
+ lucideFallback: 'activity',
+ },
+
+ // Cloud services
+ { patterns: [/^s3$/i], iconSearch: 's3', catalog: 'aws', lucideFallback: 'folder' },
+ { patterns: [/^lambda$/i], iconSearch: 'lambda', catalog: 'aws', lucideFallback: 'zap' },
+ { patterns: [/^ec2$/i], iconSearch: 'ec2', catalog: 'aws', lucideFallback: 'server' },
+ { patterns: [/^ecs$/i], iconSearch: 'ecs', catalog: 'aws', lucideFallback: 'container' },
+ { patterns: [/^eks$/i], iconSearch: 'eks', catalog: 'aws', lucideFallback: 'container' },
+ { patterns: [/^rds$/i], iconSearch: 'rds', catalog: 'aws', lucideFallback: 'database' },
+ {
+ patterns: [/^api\s*gateway$/i],
+ iconSearch: 'api-gateway',
+ catalog: 'aws',
+ lucideFallback: 'shield',
+ },
+ {
+ patterns: [/^cloudfront$/i],
+ iconSearch: 'cloudfront',
+ catalog: 'aws',
+ lucideFallback: 'globe',
+ },
+ { patterns: [/^sqs$/i], iconSearch: 'sqs', catalog: 'aws', lucideFallback: 'layers' },
+ { patterns: [/^sns$/i], iconSearch: 'sns', catalog: 'aws', lucideFallback: 'bell' },
+ { patterns: [/^cognito$/i], iconSearch: 'cognito', catalog: 'aws', lucideFallback: 'key' },
+ {
+ patterns: [/^cloud\s*run$/i],
+ iconSearch: 'cloud-run',
+ catalog: 'gcp',
+ lucideFallback: 'container',
+ },
+ {
+ patterns: [/^cloud\s*functions$/i],
+ iconSearch: 'cloud-functions',
+ catalog: 'gcp',
+ lucideFallback: 'zap',
+ },
+ { patterns: [/^bigquery$/i], iconSearch: 'bigquery', catalog: 'gcp', lucideFallback: 'database' },
+ {
+ patterns: [/^azure\s*functions$/i],
+ iconSearch: 'azure-functions',
+ catalog: 'azure',
+ lucideFallback: 'zap',
+ },
+ {
+ patterns: [/^azure\s*sql$/i],
+ iconSearch: 'azure-sql',
+ catalog: 'azure',
+ lucideFallback: 'database',
+ },
+
+ // Messaging / Streaming
+ { patterns: [/^pulsar$/i], iconSearch: 'pulsar', catalog: 'developer', lucideFallback: 'layers' },
+ { patterns: [/^nats$/i], iconSearch: 'nats', catalog: 'developer', lucideFallback: 'layers' },
+ {
+ patterns: [/^zeromq$/i, /^0mq$/i],
+ iconSearch: 'zeromq',
+ catalog: 'developer',
+ lucideFallback: 'layers',
+ },
+
+ // Auth
+ { patterns: [/^auth0$/i], iconSearch: 'auth0', catalog: 'developer', lucideFallback: 'key' },
+ {
+ patterns: [/^keycloak$/i],
+ iconSearch: 'keycloak',
+ catalog: 'developer',
+ lucideFallback: 'key',
+ },
+ {
+ patterns: [/^firebase$/i],
+ iconSearch: 'firebase',
+ catalog: 'developer',
+ lucideFallback: 'flame',
+ },
+ {
+ patterns: [/^supertokens$/i, /^super\s*tokens$/i],
+ iconSearch: 'supertokens',
+ catalog: 'developer',
+ lucideFallback: 'key',
+ },
+
+ // Payments / SaaS
+ {
+ patterns: [/^stripe$/i],
+ iconSearch: 'stripe',
+ catalog: 'developer',
+ lucideFallback: 'credit-card',
+ },
+ { patterns: [/^twilio$/i], iconSearch: 'twilio', catalog: 'developer', lucideFallback: 'phone' },
+ {
+ patterns: [/^sendgrid$/i],
+ iconSearch: 'sendgrid',
+ catalog: 'developer',
+ lucideFallback: 'mail',
+ },
+ {
+ patterns: [/^mailchimp$/i],
+ iconSearch: 'mailchimp',
+ catalog: 'developer',
+ lucideFallback: 'mail',
+ },
+ {
+ patterns: [/^cloudflare$/i],
+ iconSearch: 'cloudflare',
+ catalog: 'developer',
+ lucideFallback: 'cloud',
+ },
+ {
+ patterns: [/^vercel$/i],
+ iconSearch: 'vercel',
+ catalog: 'developer',
+ lucideFallback: 'triangle',
+ },
+ {
+ patterns: [/^netlify$/i],
+ iconSearch: 'netlify',
+ catalog: 'developer',
+ lucideFallback: 'globe',
+ },
+];
+
+const LUCIDE_FALLBACK_MAP: Record = {
+ database: 'database',
+ cache: 'hard-drive',
+ queue: 'layers',
+ service: 'server',
+ frontend: 'monitor',
+ gateway: 'shield',
+ auth: 'key-round',
+ storage: 'folder',
+ user: 'user',
+ start: 'play',
+ end: 'check-circle',
+ decision: 'help-circle',
+ action: 'zap',
+ process: 'box',
+};
+
+export function resolveIconSync(query: string, categoryHint?: string): IconResolution {
+ const trimmed = query.trim();
+ if (!trimmed) {
+ return { found: false, confidence: 0 };
+ }
+
+ for (const entry of ALIAS_TABLE) {
+ if (entry.patterns.some((p) => p.test(trimmed))) {
+ return {
+ found: true,
+ iconSearch: entry.iconSearch,
+ catalog: entry.catalog,
+ lucideIcon: entry.lucideFallback,
+ label: trimmed,
+ confidence: 0.95,
+ };
+ }
+ }
+
+ if (categoryHint && LUCIDE_FALLBACK_MAP[categoryHint]) {
+ return {
+ found: true,
+ lucideIcon: LUCIDE_FALLBACK_MAP[categoryHint],
+ label: trimmed,
+ confidence: 0.5,
+ };
+ }
+
+ return { found: false, confidence: 0 };
+}
+
+export function resolveLucideFallback(category: string): string {
+ return LUCIDE_FALLBACK_MAP[category] ?? 'box';
+}
diff --git a/src/lib/mermaidEnrichmentPipeline.test.ts b/src/lib/mermaidEnrichmentPipeline.test.ts
new file mode 100644
index 00000000..ceb48192
--- /dev/null
+++ b/src/lib/mermaidEnrichmentPipeline.test.ts
@@ -0,0 +1,192 @@
+import { describe, expect, it } from 'vitest';
+import { parseMermaidByType } from '@/services/mermaid/parseMermaidByType';
+import { enrichNodesWithIcons } from '@/lib/nodeEnricher';
+
+describe('Mermaid โ Enrichment Pipeline (E2E)', () => {
+ it('flowchart: assigns colors and icons to all node types', async () => {
+ const mermaid = `
+ flowchart TD
+ S([Start]) --> login[Login Form]
+ login --> valid{Credentials Valid?}
+ valid -->|Yes| db[(PostgreSQL)]
+ valid -->|No| fail((Access Denied))
+ db --> redis[Redis Cache]
+ redis --> done((Dashboard))
+ `;
+
+ const parsed = parseMermaidByType(mermaid);
+ expect(parsed.error).toBeUndefined();
+ expect(parsed.nodes.length).toBeGreaterThan(0);
+
+ const enriched = await enrichNodesWithIcons(parsed.nodes);
+
+ const startNode = enriched.find((n) => n.id === 'S');
+ expect(startNode?.data.color).toBe('emerald');
+ expect(startNode?.data.icon).toBe('play');
+
+ const endNode = enriched.find((n) => n.id === 'fail');
+ expect(endNode?.data.color).toBe('red');
+ expect(endNode?.data.icon).toBe('check-circle');
+
+ const decisionNode = enriched.find((n) => n.id === 'valid');
+ expect(decisionNode?.data.color).toBe('amber');
+ expect(decisionNode?.data.icon).toBe('help-circle');
+
+ const dbNode = enriched.find((n) => n.id === 'db');
+ expect(dbNode?.data.color).toBe('violet');
+ expect(dbNode?.data.archIconPackId).toBeTruthy();
+ expect(dbNode?.data.assetProvider).toBeTruthy();
+
+ const redisNode = enriched.find((n) => n.id === 'redis');
+ expect(redisNode?.data.color).toBe('red');
+ expect(redisNode?.data.archIconPackId).toBeTruthy();
+ expect(redisNode?.data.assetProvider).toBeTruthy();
+ });
+
+ it('flowchart with subgraphs: creates section nodes with proper hierarchy', async () => {
+ const mermaid = `
+ flowchart TD
+ subgraph Backend
+ API[Express API]
+ DB[(PostgreSQL)]
+ end
+ subgraph Frontend
+ UI[React App]
+ end
+ UI --> API
+ API --> DB
+ `;
+
+ const parsed = parseMermaidByType(mermaid);
+ expect(parsed.error).toBeUndefined();
+
+ const enriched = await enrichNodesWithIcons(parsed.nodes);
+
+ const sectionNodes = enriched.filter((n) => n.type === 'section');
+ expect(sectionNodes).toHaveLength(2);
+
+ const backendSection = sectionNodes.find((n) => n.data.label === 'Backend');
+ expect(backendSection).toBeDefined();
+
+ const apiNode = enriched.find((n) => n.id === 'API');
+ expect(apiNode?.parentId).toBe(backendSection?.id);
+ expect(apiNode?.data.color).toBe('blue');
+
+ const dbNode = enriched.find((n) => n.id === 'DB');
+ expect(dbNode?.parentId).toBe(backendSection?.id);
+ expect(dbNode?.data.color).toBe('violet');
+ });
+
+ it('sequence diagram: parses participants and messages', async () => {
+ const mermaid = `
+ sequenceDiagram
+ participant Client
+ participant Server
+ participant Database
+ Client->>Server: HTTP Request
+ Server->>Database: SQL Query
+ Database-->>Server: Results
+ Server-->>Client: JSON Response
+ `;
+
+ const parsed = parseMermaidByType(mermaid);
+ expect(parsed.error).toBeUndefined();
+ expect(parsed.diagramType).toBe('sequence');
+ expect(parsed.nodes.length).toBeGreaterThanOrEqual(3);
+ expect(parsed.edges.length).toBeGreaterThanOrEqual(4);
+ });
+
+ it('sequence diagram: handles fragments (alt/loop) and activations', async () => {
+ const mermaid = `
+ sequenceDiagram
+ participant A
+ participant B
+ A->>B: Request
+ activate B
+ alt success
+ B-->>A: 200 OK
+ else failure
+ B-->>A: 500 Error
+ end
+ loop every minute
+ A->>B: Heartbeat
+ end
+ deactivate B
+ `;
+
+ const parsed = parseMermaidByType(mermaid);
+ expect(parsed.error).toBeUndefined();
+ expect(parsed.diagramType).toBe('sequence');
+ expect(parsed.nodes.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it('sequence diagram: handles notes over participants', async () => {
+ const mermaid = `
+ sequenceDiagram
+ participant A
+ participant B
+ Note over A,B: This is a note
+ A->>B: Message
+ `;
+
+ const parsed = parseMermaidByType(mermaid);
+ expect(parsed.error).toBeUndefined();
+ expect(parsed.nodes.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it('architecture diagram: preserves archIconPackId when set by AI', async () => {
+ // Simulate AI-generated nodes with provider icons already set
+ const aiGeneratedNodes = [
+ {
+ id: 'api_gw',
+ type: 'architecture' as const,
+ position: { x: 0, y: 0 },
+ data: {
+ label: 'API Gateway',
+ subLabel: '',
+ color: 'violet',
+ archIconPackId: 'aws-official-starter-v1',
+ archIconShapeId: 'api-gateway',
+ assetPresentation: 'icon' as const,
+ },
+ },
+ ];
+
+ const enriched = await enrichNodesWithIcons(aiGeneratedNodes);
+
+ // Enricher should preserve existing archIconPackId
+ expect(enriched[0].data.archIconPackId).toBe('aws-official-starter-v1');
+ expect(enriched[0].data.archIconShapeId).toBe('api-gateway');
+ expect(enriched[0].data.assetProvider).toBe('aws');
+ expect(enriched[0].data.color).toBe('violet');
+ });
+
+ it('does not modify section nodes', async () => {
+ const mermaid = `
+ flowchart TD
+ subgraph Group A
+ A[Node A]
+ end
+ `;
+
+ const parsed = parseMermaidByType(mermaid);
+ const enriched = await enrichNodesWithIcons(parsed.nodes);
+
+ const sectionNode = enriched.find((n) => n.type === 'section');
+ expect(sectionNode?.data.icon).toBeUndefined();
+ expect(sectionNode?.data.archIconPackId).toBeUndefined();
+ });
+
+ it('edge labels are preserved through parse+enrich', async () => {
+ const mermaid = `
+ flowchart TD
+ A[Start] -->|Yes| B[Process]
+ A -->|No| C[End]
+ `;
+
+ const parsed = parseMermaidByType(mermaid);
+ expect(parsed.edges).toHaveLength(2);
+ expect(parsed.edges[0].label).toBe('Yes');
+ expect(parsed.edges[1].label).toBe('No');
+ });
+});
diff --git a/src/lib/mermaidParser.ts b/src/lib/mermaidParser.ts
index 3b74e02b..6d6f5f0d 100644
--- a/src/lib/mermaidParser.ts
+++ b/src/lib/mermaidParser.ts
@@ -1,5 +1,6 @@
import { MarkerType } from '@/lib/reactflowCompat';
import { createDefaultEdge } from '@/constants';
+import { SECTION_MIN_HEIGHT, SECTION_MIN_WIDTH } from '@/hooks/node-operations/sectionBounds';
import { setNodeParent } from './nodeParent';
import {
createMermaidParseState,
@@ -9,341 +10,472 @@ import {
type MermaidParseModel,
} from './mermaidParserModel';
import {
- ARROW_PATTERNS,
- CLASS_DEF_RE,
- parseEdgeLine,
- parseLinkStyleLine,
- parseNodeDeclaration,
- parseStyleString,
- SKIP_PATTERNS,
- STYLE_RE,
- normalizeEdgeLabels,
- normalizeMultilineStrings,
+ ARROW_PATTERNS,
+ CLASS_DEF_RE,
+ parseEdgeLine,
+ parseClassAssignmentLine,
+ parseLinkStyleLine,
+ parseNodeDeclaration,
+ parseStyleString,
+ SKIP_PATTERNS,
+ STYLE_RE,
+ normalizeEdgeLabels,
+ normalizeMultilineStrings,
} from './mermaidParserHelpers';
import type { FlowEdge, FlowNode } from './types';
const NODE_TYPE_DEFAULTS: Record = {
- start: 'emerald',
- end: 'red',
- decision: 'amber',
- custom: 'violet',
- process: 'slate',
+ start: 'emerald',
+ end: 'red',
+ decision: 'amber',
+ custom: 'violet',
+ process: 'slate',
};
function getDefaultColor(type: string): string {
- return NODE_TYPE_DEFAULTS[type] || 'slate';
+ return NODE_TYPE_DEFAULTS[type] || 'slate';
}
export interface ParseResult {
- nodes: FlowNode[];
- edges: FlowEdge[];
- error?: string;
- direction?: MermaidDirection;
+ nodes: FlowNode[];
+ edges: FlowEdge[];
+ error?: string;
+ direction?: MermaidDirection;
+ diagnostics?: string[];
}
function preprocessMermaidInput(input: string): string[] {
- const processed = normalizeEdgeLabels(normalizeMultilineStrings(input.replace(/\r\n/g, '\n')));
- return processed.split('\n');
+ const processed = normalizeEdgeLabels(normalizeMultilineStrings(input.replace(/\r\n/g, '\n')));
+ return processed.split('\n');
}
function isSkippableLine(line: string): boolean {
- return !line || SKIP_PATTERNS.some((pattern) => pattern.test(line));
+ return !line || SKIP_PATTERNS.some((pattern) => pattern.test(line));
}
function parseFlowchartDeclaration(line: string): MermaidDirection | null {
- const flowchartMatch = line.match(/^(?:flowchart|graph)\s+(TD|TB|LR|RL|BT)/i);
- if (!flowchartMatch) {
- return null;
- }
-
- return (flowchartMatch[1].toUpperCase() === 'TD'
- ? 'TB'
- : flowchartMatch[1].toUpperCase()) as MermaidDirection;
+ const flowchartMatch = line.match(/^(?:flowchart|graph)\s+(TD|TB|LR|RL|BT)/i);
+ if (!flowchartMatch) {
+ return null;
+ }
+
+ return (
+ flowchartMatch[1].toUpperCase() === 'TD' ? 'TB' : flowchartMatch[1].toUpperCase()
+ ) as MermaidDirection;
}
function parseStateDiagramDirection(nextLine: string | undefined): MermaidDirection {
- const dirMatch = nextLine?.trim().match(/^direction\s+(LR|TB)/i);
- return (dirMatch?.[1].toUpperCase() ?? 'TB') as MermaidDirection;
+ const dirMatch = nextLine?.trim().match(/^direction\s+(LR|TB)/i);
+ return (dirMatch?.[1].toUpperCase() ?? 'TB') as MermaidDirection;
+}
+
+function createSectionIdFromLabel(label: string): string {
+ return `subgraph_${label.replace(/[^a-zA-Z0-9_]/g, '_')}`;
+}
+
+function parseSubgraphDeclaration(
+ line: string
+): { sectionId: string; sectionLabel: string } | null {
+ const subgraphMatch = line.match(/^subgraph\s+(.+)$/i);
+ if (!subgraphMatch) {
+ return null;
+ }
+
+ const remainder = subgraphMatch[1].trim();
+ if (!remainder) {
+ return null;
+ }
+
+ const parsedNodeDeclaration = parseNodeDeclaration(remainder);
+ if (parsedNodeDeclaration) {
+ return {
+ sectionId: parsedNodeDeclaration.id,
+ sectionLabel: parsedNodeDeclaration.label,
+ };
+ }
+
+ const quotedLabelMatch = remainder.match(/^"([^"]+)"$/) || remainder.match(/^'([^']+)'$/);
+ const sectionLabel = quotedLabelMatch?.[1]?.trim() ?? remainder;
+ if (!sectionLabel) {
+ return null;
+ }
+
+ return {
+ sectionId: createSectionIdFromLabel(sectionLabel),
+ sectionLabel,
+ };
}
function registerSectionNode(
- state: ReturnType,
- line: string
+ state: ReturnType,
+ line: string
): boolean {
- const subgraphMatch = line.match(/^subgraph\s+(.+)$/i);
- const stateGroupMatch =
- line.match(/^state\s+"([^"]+)"\s+as\s+(\w+)\s+\{/i) ||
- line.match(/^state\s+(\w+)\s+\{/i);
+ const subgraphDeclaration = parseSubgraphDeclaration(line);
+ const stateGroupMatch =
+ line.match(/^state\s+"([^"]+)"\s+as\s+(\w+)\s+\{/i) || line.match(/^state\s+(\w+)\s+\{/i);
- if (!subgraphMatch && !stateGroupMatch) {
- return false;
- }
- void state;
- return true;
+ if (!subgraphDeclaration && !stateGroupMatch) {
+ return false;
+ }
+
+ let sectionId: string;
+ let sectionLabel: string;
+
+ if (subgraphDeclaration) {
+ sectionLabel = subgraphDeclaration.sectionLabel;
+ sectionId = subgraphDeclaration.sectionId;
+ } else if (stateGroupMatch) {
+ sectionId = stateGroupMatch[2] ?? stateGroupMatch[1];
+ sectionLabel = stateGroupMatch[1] ?? stateGroupMatch[2];
+ } else {
+ return false;
+ }
+
+ let attempts = 0;
+ let finalId = sectionId;
+ while (state.nodesMap.has(finalId)) {
+ finalId = `${sectionId}_${++attempts}`;
+ }
+
+ const parentId = state.parentStack[state.parentStack.length - 1];
+ state.nodesMap.set(finalId, {
+ id: finalId,
+ label: sectionLabel,
+ type: 'section',
+ parentId,
+ });
+ state.parentStack.push(finalId);
+
+ return true;
}
function applyNodeStyleDirective(
- state: ReturnType,
- line: string
+ state: ReturnType,
+ line: string
): boolean {
- const styleMatch = line.match(STYLE_RE);
- if (!styleMatch) {
- return false;
+ const styleMatch = line.match(STYLE_RE);
+ if (!styleMatch) {
+ return false;
+ }
+
+ const [, id, styleStr] = styleMatch;
+ const styles = parseStyleString(styleStr);
+ const node = state.nodesMap.get(id);
+ if (node) {
+ node.styles = { ...node.styles, ...styles };
+ } else {
+ registerMermaidNode(state, id);
+ const registeredNode = state.nodesMap.get(id);
+ if (registeredNode) {
+ registeredNode.styles = styles;
}
+ }
- const [, id, styleStr] = styleMatch;
- const styles = parseStyleString(styleStr);
- const node = state.nodesMap.get(id);
- if (node) {
- node.styles = { ...node.styles, ...styles };
- } else {
- registerMermaidNode(state, id);
- const registeredNode = state.nodesMap.get(id);
- if (registeredNode) {
- registeredNode.styles = styles;
- }
+ return true;
+}
+
+function applyClassAssignmentDirective(
+ state: ReturnType,
+ line: string
+): boolean {
+ const assignment = parseClassAssignmentLine(line);
+ if (!assignment) {
+ return false;
+ }
+
+ assignment.nodeIds.forEach((nodeId) => {
+ registerMermaidNode(state, nodeId);
+ const node = state.nodesMap.get(nodeId);
+ if (!node) {
+ return;
}
- return true;
+ const existingClasses = new Set(node.classes ?? []);
+ assignment.classNames.forEach((className) => existingClasses.add(className));
+ node.classes = [...existingClasses];
+ });
+
+ return true;
}
function parseEdgeDeclaration(
- state: ReturnType,
- line: string
+ state: ReturnType,
+ line: string,
+ lineNumber: number
): boolean {
- if (!ARROW_PATTERNS.some((arrow) => line.includes(arrow))) {
- return false;
- }
-
- const edgesFound = parseEdgeLine(line);
- edgesFound.forEach((edge) => {
- const type = state.diagramType === 'stateDiagram' ? 'state' : 'process';
- const sourceId = registerMermaidNode(state, edge.sourceRaw, type);
- const targetId = registerMermaidNode(state, edge.targetRaw, type);
-
- if (sourceId && targetId) {
- state.rawEdges.push({
- source: sourceId,
- target: targetId,
- label: edge.label,
- arrowType: edge.arrowType,
- });
- }
- });
+ if (!ARROW_PATTERNS.some((arrow) => line.includes(arrow))) {
+ return false;
+ }
+ const edgesFound = parseEdgeLine(line);
+ if (edgesFound.length === 0) {
+ state.diagnostics.push(`Invalid Mermaid edge syntax at line ${lineNumber}: "${line}"`);
return true;
+ }
+
+ edgesFound.forEach((edge) => {
+ const type = state.diagramType === 'stateDiagram' ? 'state' : 'process';
+ const sourceId = registerMermaidNode(state, edge.sourceRaw, type);
+ const targetId = registerMermaidNode(state, edge.targetRaw, type);
+
+ if (sourceId && targetId) {
+ state.rawEdges.push({
+ source: sourceId,
+ target: targetId,
+ label: edge.label,
+ arrowType: edge.arrowType,
+ });
+ }
+ });
+
+ return true;
}
function parseStateDiagramNodeDeclaration(
- state: ReturnType,
- line: string
+ state: ReturnType,
+ line: string
): boolean {
- if (state.diagramType !== 'stateDiagram') {
- return false;
- }
+ if (state.diagramType !== 'stateDiagram') {
+ return false;
+ }
- const stateDefMatch = line.match(/^state\s+"([^"]+)"\s+as\s+(\w+)/i);
- if (stateDefMatch) {
- registerMermaidNode(state, stateDefMatch[2], 'state', stateDefMatch[1]);
- return true;
- }
+ const stateDefMatch = line.match(/^state\s+"([^"]+)"\s+as\s+([A-Za-z_][\w.-]*)/i);
+ if (stateDefMatch) {
+ registerMermaidNode(state, stateDefMatch[2], 'state', stateDefMatch[1]);
+ return true;
+ }
- const stateDescMatch = line.match(/^(\w+)\s*:\s*(.+)/);
- if (stateDescMatch) {
- registerMermaidNode(state, stateDescMatch[1], 'state', stateDescMatch[2]);
- return true;
- }
+ const stateDescMatch = line.match(/^(\w+)\s*:\s*(.+)/);
+ if (stateDescMatch) {
+ registerMermaidNode(state, stateDescMatch[1], 'state', stateDescMatch[2]);
+ return true;
+ }
- return false;
+ return false;
}
function buildMermaidParseModel(lines: string[]): MermaidParseModel {
- const state = createMermaidParseState();
+ const state = createMermaidParseState();
- for (let i = 0; i < lines.length; i++) {
- const line = lines[i].trim();
- if (isSkippableLine(line)) {
- continue;
- }
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i].trim();
+ const lineNumber = i + 1;
+ if (isSkippableLine(line)) {
+ continue;
+ }
- const flowchartDirection = parseFlowchartDeclaration(line);
- if (flowchartDirection) {
- state.diagramType = 'flowchart';
- state.direction = flowchartDirection;
- continue;
- }
+ const flowchartDirection = parseFlowchartDeclaration(line);
+ if (flowchartDirection) {
+ state.diagramType = 'flowchart';
+ state.direction = flowchartDirection;
+ continue;
+ }
- if (line.match(/^stateDiagram(?:-v2)?/i)) {
- state.diagramType = 'stateDiagram';
- state.direction = parseStateDiagramDirection(lines[i + 1]);
- continue;
- }
+ if (line.match(/^stateDiagram(?:-v2)?/i)) {
+ state.diagramType = 'stateDiagram';
+ state.direction = parseStateDiagramDirection(lines[i + 1]);
+ continue;
+ }
- if (line.match(/^end\s*$/i) || line === '}') {
- if (state.parentStack.length > 0) {
- state.parentStack.pop();
- }
- continue;
- }
+ if (line.match(/^end\s*$/i) || line === '}') {
+ if (state.parentStack.length > 0) {
+ state.parentStack.pop();
+ } else if (state.diagramType === 'flowchart') {
+ state.diagnostics.push(`Unexpected flowchart block closer at line ${lineNumber}: "${line}"`);
+ }
+ continue;
+ }
- if (registerSectionNode(state, line)) {
- continue;
- }
+ if (state.diagramType === 'flowchart' && /^subgraph\b/i.test(line) && !parseSubgraphDeclaration(line)) {
+ state.diagnostics.push(`Invalid flowchart subgraph declaration at line ${lineNumber}: "${line}"`);
+ continue;
+ }
- const classDefMatch = line.match(CLASS_DEF_RE);
- if (classDefMatch) {
- state.classDefs.set(classDefMatch[1], parseStyleString(classDefMatch[2]));
- continue;
- }
+ if (registerSectionNode(state, line)) {
+ continue;
+ }
- if (applyNodeStyleDirective(state, line)) {
- continue;
- }
+ const classDefMatch = line.match(CLASS_DEF_RE);
+ if (classDefMatch) {
+ state.classDefs.set(classDefMatch[1], parseStyleString(classDefMatch[2]));
+ continue;
+ }
- const linkStyleMatch = parseLinkStyleLine(line);
- if (linkStyleMatch) {
- linkStyleMatch.indices.forEach((index) => state.linkStyles.set(index, linkStyleMatch.style));
- continue;
- }
+ if (applyNodeStyleDirective(state, line)) {
+ continue;
+ }
- if (parseEdgeDeclaration(state, line)) {
- continue;
- }
+ if (applyClassAssignmentDirective(state, line)) {
+ continue;
+ }
- if (parseStateDiagramNodeDeclaration(state, line)) {
- continue;
- }
+ const linkStyleMatch = parseLinkStyleLine(line);
+ if (linkStyleMatch) {
+ linkStyleMatch.indices.forEach((index) => state.linkStyles.set(index, linkStyleMatch.style));
+ continue;
+ }
- const standalone = parseNodeDeclaration(line);
- if (standalone) {
- registerMermaidNode(state, line);
- }
+ if (parseEdgeDeclaration(state, line, lineNumber)) {
+ continue;
}
- return toMermaidParseModel(state);
-}
+ if (parseStateDiagramNodeDeclaration(state, line)) {
+ continue;
+ }
-function createFlowNodes(model: MermaidParseModel): FlowNode[] {
- return Array.from(model.nodesMap.values()).map((node, index) => {
- let flowNode: FlowNode = {
- id: node.id,
- type: node.type,
- position: { x: (index % 4) * 200, y: Math.floor(index / 4) * 150 },
- data: {
- label: node.label,
- subLabel: '',
- color: getDefaultColor(node.type),
- ...(node.shape ? { shape: node.shape } : {}),
- },
- };
+ const standalone = parseNodeDeclaration(line);
+ if (standalone) {
+ registerMermaidNode(state, line);
+ continue;
+ }
- if (node.parentId) {
- flowNode = setNodeParent(flowNode, node.parentId);
- }
+ if (state.diagramType === 'flowchart') {
+ state.diagnostics.push(`Unrecognized flowchart line at line ${lineNumber}: "${line}"`);
+ }
+ }
- if (node.classes) {
- node.classes.forEach((cls) => {
- const styles = model.classDefs.get(cls);
- if (!styles) {
- return;
- }
- if (styles.fill) {
- flowNode.style = { ...flowNode.style, backgroundColor: styles.fill };
- }
- if (styles.stroke) {
- flowNode.style = { ...flowNode.style, borderColor: styles.stroke };
- }
- if (styles.color) {
- flowNode.style = { ...flowNode.style, color: styles.color };
- }
- });
- }
+ if (state.diagramType === 'flowchart' && state.parentStack.length > 0) {
+ state.diagnostics.push(
+ `Unclosed flowchart block detected (${state.parentStack.length} block(s) not closed).`
+ );
+ }
- if (node.styles) {
- if (node.styles.fill) {
- flowNode.style = { ...flowNode.style, backgroundColor: node.styles.fill };
- }
- if (node.styles.stroke) {
- flowNode.style = { ...flowNode.style, borderColor: node.styles.stroke };
- }
- if (node.styles.color) {
- flowNode.style = { ...flowNode.style, color: node.styles.color };
- }
- }
+ return toMermaidParseModel(state);
+}
- if (model.diagramType === 'stateDiagram') {
- if (node.type === 'start') {
- flowNode.style = {
- ...flowNode.style,
- width: 20,
- height: 20,
- borderRadius: '50%',
- backgroundColor: '#000',
- };
- flowNode.data.label = '';
- }
- if (node.type === 'state') {
- flowNode.data.shape = 'rounded';
- }
- }
+function createFlowNodes(model: MermaidParseModel): FlowNode[] {
+ return Array.from(model.nodesMap.values()).map((node, index) => {
+ let flowNode: FlowNode = {
+ id: node.id,
+ type: node.type,
+ position: { x: (index % 4) * 200, y: Math.floor(index / 4) * 150 },
+ data: {
+ label: node.label,
+ subLabel: '',
+ color: getDefaultColor(node.type),
+ ...(node.shape ? { shape: node.shape } : {}),
+ },
+ ...(node.type === 'section'
+ ? {
+ style: { width: SECTION_MIN_WIDTH, height: SECTION_MIN_HEIGHT },
+ }
+ : {}),
+ };
- return flowNode;
- });
-}
+ if (node.parentId) {
+ flowNode = setNodeParent(flowNode, node.parentId, {
+ constrainToParent: false,
+ });
+ }
-function createFlowEdges(model: MermaidParseModel): FlowEdge[] {
- return model.rawEdges.map((edge, index) => {
- const flowEdge = createDefaultEdge(
- edge.source,
- edge.target,
- edge.label || undefined,
- `e-mermaid-${index}`
- );
-
- if (edge.arrowType.includes('-.') || edge.arrowType.includes('-.-')) {
- flowEdge.style = { ...flowEdge.style, strokeDasharray: '5 3' };
+ if (node.classes) {
+ node.classes.forEach((cls) => {
+ const styles = model.classDefs.get(cls);
+ if (!styles) {
+ return;
}
- if (edge.arrowType.includes('==')) {
- flowEdge.style = { ...flowEdge.style, strokeWidth: 4 };
+ if (styles.fill) {
+ flowNode.style = { ...flowNode.style, backgroundColor: styles.fill };
}
- if (edge.arrowType.startsWith('<')) {
- flowEdge.markerStart = { type: MarkerType.ArrowClosed };
+ if (styles.stroke) {
+ flowNode.style = { ...flowNode.style, borderColor: styles.stroke };
}
- if (!edge.arrowType.includes('>')) {
- flowEdge.markerEnd = undefined;
+ if (styles.color) {
+ flowNode.style = { ...flowNode.style, color: styles.color };
}
+ });
+ }
- const style = model.linkStyles.get(index);
- if (style) {
- if (style.stroke) {
- flowEdge.style = { ...flowEdge.style, stroke: style.stroke };
- }
- if (style['stroke-width']) {
- flowEdge.style = {
- ...flowEdge.style,
- strokeWidth: parseInt(style['stroke-width'], 10) || 2,
- };
- }
- }
+ if (node.styles) {
+ if (node.styles.fill) {
+ flowNode.style = { ...flowNode.style, backgroundColor: node.styles.fill };
+ }
+ if (node.styles.stroke) {
+ flowNode.style = { ...flowNode.style, borderColor: node.styles.stroke };
+ }
+ if (node.styles.color) {
+ flowNode.style = { ...flowNode.style, color: node.styles.color };
+ }
+ }
- return flowEdge;
- });
-}
+ if (model.diagramType === 'stateDiagram') {
+ if (node.type === 'start') {
+ flowNode.style = {
+ ...flowNode.style,
+ width: 20,
+ height: 20,
+ borderRadius: '50%',
+ backgroundColor: '#000',
+ };
+ flowNode.data.label = '';
+ }
+ if (node.type === 'state') {
+ flowNode.data.shape = 'rounded';
+ }
+ }
-export function parseMermaid(input: string): ParseResult {
- const model = buildMermaidParseModel(preprocessMermaidInput(input));
+ return flowNode;
+ });
+}
- if (model.diagramType === 'unknown') {
- return { nodes: [], edges: [], error: 'Missing chart type declaration. Start with "flowchart TD" or related.' };
+function createFlowEdges(model: MermaidParseModel): FlowEdge[] {
+ return model.rawEdges.map((edge, index) => {
+ const flowEdge = createDefaultEdge(
+ edge.source,
+ edge.target,
+ edge.label || undefined,
+ `e-mermaid-${index}`
+ );
+
+ if (edge.arrowType.includes('-.') || edge.arrowType.includes('-.-')) {
+ flowEdge.style = { ...flowEdge.style, strokeDasharray: '5 3' };
+ }
+ if (edge.arrowType.includes('==')) {
+ flowEdge.style = { ...flowEdge.style, strokeWidth: 4 };
+ }
+ if (edge.arrowType.startsWith('<')) {
+ flowEdge.markerStart = { type: MarkerType.ArrowClosed };
+ }
+ if (!edge.arrowType.includes('>')) {
+ flowEdge.markerEnd = undefined;
}
- if (model.nodesMap.size === 0) {
- return { nodes: [], edges: [], error: 'No valid nodes found.' };
+ const style = model.linkStyles.get(index);
+ if (style) {
+ if (style.stroke) {
+ flowEdge.style = { ...flowEdge.style, stroke: style.stroke };
+ }
+ if (style['stroke-width']) {
+ flowEdge.style = {
+ ...flowEdge.style,
+ strokeWidth: parseInt(style['stroke-width'], 10) || 2,
+ };
+ }
}
+ return flowEdge;
+ });
+}
+
+export function parseMermaid(input: string): ParseResult {
+ const model = buildMermaidParseModel(preprocessMermaidInput(input));
+
+ if (model.diagramType === 'unknown') {
return {
- nodes: createFlowNodes(model),
- edges: createFlowEdges(model),
- direction: model.direction,
+ nodes: [],
+ edges: [],
+ error: 'Missing chart type declaration. Start with "flowchart TD" or related.',
};
+ }
+
+ if (model.nodesMap.size === 0) {
+ return { nodes: [], edges: [], error: 'No valid nodes found.' };
+ }
+
+ return {
+ nodes: createFlowNodes(model),
+ edges: createFlowEdges(model),
+ direction: model.direction,
+ diagnostics: model.diagnostics.length > 0 ? model.diagnostics : undefined,
+ };
}
diff --git a/src/lib/mermaidParserHelpers.ts b/src/lib/mermaidParserHelpers.ts
index 4ee7bdc6..c1c4f111 100644
--- a/src/lib/mermaidParserHelpers.ts
+++ b/src/lib/mermaidParserHelpers.ts
@@ -1,257 +1,512 @@
import type { NodeData } from './types';
-export const SHAPE_OPENERS: Array<{ open: string; close: string; type: string; shape: NodeData['shape'] }> = [
- { open: '([', close: '])', type: 'start', shape: 'capsule' },
- { open: '((', close: '))', type: 'end', shape: 'circle' },
- { open: '{{', close: '}}', type: 'custom', shape: 'hexagon' },
- { open: '[(', close: ')]', type: 'process', shape: 'cylinder' },
- { open: '{', close: '}', type: 'decision', shape: 'diamond' },
- { open: '[', close: ']', type: 'process', shape: 'rounded' },
- { open: '(', close: ')', type: 'process', shape: 'rounded' },
- { open: '>', close: ']', type: 'process', shape: 'parallelogram' },
+export const SHAPE_OPENERS: Array<{
+ open: string;
+ close: string;
+ type: string;
+ shape: NodeData['shape'];
+}> = [
+ { open: '([', close: '])', type: 'start', shape: 'capsule' },
+ { open: '((', close: '))', type: 'end', shape: 'circle' },
+ { open: '{{', close: '}}', type: 'custom', shape: 'hexagon' },
+ { open: '[(', close: ')]', type: 'process', shape: 'cylinder' },
+ { open: '{', close: '}', type: 'decision', shape: 'diamond' },
+ { open: '[', close: ']', type: 'process', shape: 'rounded' },
+ { open: '(', close: ')', type: 'process', shape: 'rounded' },
+ { open: '>', close: ']', type: 'process', shape: 'parallelogram' },
];
export const SKIP_PATTERNS = [
- /^%%/,
- /^class\s/i,
- /^click\s/i,
- /^direction\s/i,
- /^accTitle\s/i,
- /^accDescr\s/i,
+ /^%%/,
+ /^click\s/i,
+ /^direction\s/i,
+ /^accTitle\s/i,
+ /^accDescr\s/i,
];
const LINK_STYLE_RE = /^linkStyle\s+([\d,\s]+)\s+(.+)$/i;
-const CLASS_DEF_RE = /^classDef\s+(\w+)\s+(.+)$/i;
-const STYLE_RE = /^style\s+(\w+)\s+(.+)$/i;
+const MERMAID_NODE_ID_RE_SOURCE = '[a-zA-Z0-9_][\\w.-]*';
+const CLASS_DEF_RE = /^classDef\s+([\w-]+)\s+(.+)$/i;
+const STYLE_RE = new RegExp(`^style\\s+(${MERMAID_NODE_ID_RE_SOURCE})\\s+(.+)$`, 'i');
+const MERMAID_NODE_ID_RE = new RegExp(`^${MERMAID_NODE_ID_RE_SOURCE}$`);
export { CLASS_DEF_RE, STYLE_RE };
-export function parseLinkStyleLine(line: string): { indices: number[]; style: Record } | null {
- const match = line.match(LINK_STYLE_RE);
- if (!match) return null;
+export function parseClassAssignmentLine(
+ line: string
+): { nodeIds: string[]; classNames: string[] } | null {
+ const trimmed = line.trim().replace(/;$/, '');
+ const match = trimmed.match(/^class\s+(.+?)\s+([A-Za-z0-9_-]+(?:\s*,\s*[A-Za-z0-9_-]+)*)$/i);
+ if (!match) return null;
+
+ const nodeIds = match[1]
+ .split(/\s*,\s*/)
+ .map((value) => value.trim())
+ .filter((value) => MERMAID_NODE_ID_RE.test(value));
+ const classNames = match[2]
+ .split(/\s*,\s*/)
+ .map((value) => value.trim())
+ .filter(Boolean);
+
+ if (nodeIds.length === 0 || classNames.length === 0) {
+ return null;
+ }
+
+ return { nodeIds, classNames };
+}
+
+export function parseLinkStyleLine(
+ line: string
+): { indices: number[]; style: Record } | null {
+ const match = line.match(LINK_STYLE_RE);
+ if (!match) return null;
- const indices = match[1]
- .split(',')
- .map((s) => parseInt(s.trim(), 10))
- .filter((n) => !Number.isNaN(n));
+ const indices = match[1]
+ .split(',')
+ .map((s) => parseInt(s.trim(), 10))
+ .filter((n) => !Number.isNaN(n));
- const styleParts = match[2].replace(/;$/, '').split(',');
- const style: Record = {};
+ const styleParts = match[2].replace(/;$/, '').split(',');
+ const style: Record = {};
- for (const part of styleParts) {
- const [key, value] = part.split(':').map((s) => s.trim());
- if (key && value) {
- style[key] = value;
- }
+ for (const part of styleParts) {
+ const [key, value] = part.split(':').map((s) => s.trim());
+ if (key && value) {
+ style[key] = value;
}
+ }
- return { indices, style };
+ return { indices, style };
}
export function normalizeMultilineStrings(input: string): string {
- let result = '';
- let inQuote = false;
-
- for (let i = 0; i < input.length; i++) {
- const char = input[i];
- if (char === '"' && input[i - 1] !== '\\') {
- inQuote = !inQuote;
- }
-
- if (inQuote && char === '\n') {
- result += '\\n';
- let nextIndex = i + 1;
- while (nextIndex < input.length && (input[nextIndex] === ' ' || input[nextIndex] === '\t')) {
- nextIndex++;
- }
- i = nextIndex - 1;
- } else {
- result += char;
- }
+ let result = '';
+ let inQuote = false;
+
+ for (let i = 0; i < input.length; i++) {
+ const char = input[i];
+ if (char === '"' && input[i - 1] !== '\\') {
+ inQuote = !inQuote;
+ }
+
+ if (inQuote && char === '\n') {
+ result += '\\n';
+ let nextIndex = i + 1;
+ while (nextIndex < input.length && (input[nextIndex] === ' ' || input[nextIndex] === '\t')) {
+ nextIndex++;
+ }
+ i = nextIndex - 1;
+ } else {
+ result += char;
}
+ }
- return result;
+ return result;
}
export function normalizeEdgeLabels(input: string): string {
- let result = input;
- result = result.replace(/==(?![>])\s*(.+?)\s*==>/g, ' ==>|$1|');
- result = result.replace(/--(?![>-])\s*(.+?)\s*-->/g, ' -->|$1|');
- result = result.replace(/-\.\s*(.+?)\s*\.->/g, ' -.->|$1|');
- result = result.replace(/--(?![>-])\s*(.+?)\s*---/g, ' ---|$1|');
- return result;
+ let result = input;
+ // Collapse extended arrows: ---> โ -->, ====> โ ==>, -..-> โ -.->
+ // Mermaid spec allows any number of repeated chars in the arrow body.
+ result = result.replace(/={3,}>/g, '==>');
+ result = result.replace(/-{3,}>/g, '-->');
+ result = result.replace(/-\.{2,}->/g, '-.->');
+ result = result.replace(/<-{3,}>/g, '<-->');
+ result = result.replace(/<={3,}>/g, '<==>');
+ result = result.replace(/<-\.{2,}->/g, '<-.->');
+ // Inline-label arrow forms: == text ==> and -- text -->
+ result = result.replace(/==(?![>])\s*(.+?)\s*==>/g, ' ==>|$1|');
+ result = result.replace(/--(?![>-])\s*(.+?)\s*-->/g, ' -->|$1|');
+ result = result.replace(/-\.\s*(.+?)\s*\.->/g, ' -.->|$1|');
+ result = result.replace(/--(?![>-])\s*(.+?)\s*---/g, ' ---|$1|');
+ return result;
}
export interface RawNode {
- id: string;
- label: string;
- type: string;
- shape?: NodeData['shape'];
- parentId?: string;
- styles?: Record;
- classes?: string[];
+ id: string;
+ label: string;
+ type: string;
+ shape?: NodeData['shape'];
+ parentId?: string;
+ styles?: Record;
+ classes?: string[];
+}
+
+const MODERN_SHAPE_MAP: Record = {
+ cyl: { type: 'process', shape: 'cylinder' },
+ cylinder: { type: 'process', shape: 'cylinder' },
+ circle: { type: 'end', shape: 'circle' },
+ circle2: { type: 'end', shape: 'circle' },
+ cloud: { type: 'process', shape: 'rounded' },
+ diamond: { type: 'decision', shape: 'diamond' },
+ hexagon: { type: 'custom', shape: 'hexagon' },
+ 'lean-r': { type: 'process', shape: 'parallelogram' },
+ 'lean-l': { type: 'process', shape: 'parallelogram' },
+ stadium: { type: 'start', shape: 'capsule' },
+ rounded: { type: 'process', shape: 'rounded' },
+ rect: { type: 'process', shape: 'rounded' },
+ square: { type: 'process', shape: 'rounded' },
+ doublecircle: { type: 'end', shape: 'circle' },
+};
+
+interface ModernShapeAnnotation {
+ shapeKey?: string;
+ labelOverride?: string;
+ cleanInput: string;
+}
+
+function extractModernAnnotation(input: string): ModernShapeAnnotation {
+ const match = input.match(/^([a-zA-Z0-9_][\w.-]*)@\{([^}]+)\}/);
+ if (!match) return { cleanInput: input };
+
+ const id = match[1];
+ const attrs = match[2];
+ const rest = input.substring(match[0].length);
+
+ const shapeMatch = attrs.match(/\bshape:\s*(\w+)/);
+ const labelMatch = attrs.match(/\blabel:\s*"([^"]+)"/);
+
+ return {
+ shapeKey: shapeMatch?.[1]?.toLowerCase(),
+ labelOverride: labelMatch?.[1],
+ cleanInput: `${id}${rest}`,
+ };
+}
+
+function stripMarkdown(label: string): string {
+ return label
+ .replace(/\*\*(.+?)\*\*/g, '$1')
+ .replace(/\*(.+?)\*/g, '$1')
+ .replace(/__(.+?)__/g, '$1')
+ .replace(/_(.+?)_/g, '$1')
+ .replace(/~~(.+?)~~/g, '$1')
+ .replace(/`(.+?)`/g, '$1');
}
function stripFaIcons(label: string): string {
- const stripped = label.replace(/fa:fa-[\w-]+\s*/g, '').trim();
- if (stripped) return stripped;
- const iconMatch = label.match(/fa:fa-([\w-]+)/);
- return iconMatch ? iconMatch[1].replace(/-/g, ' ') : label;
+ const stripped = label.replace(/fa:fa-[\w-]+\s*/g, '').trim();
+ if (stripped) return stripped;
+ const iconMatch = label.match(/fa:fa-([\w-]+)/);
+ return iconMatch ? iconMatch[1].replace(/-/g, ' ') : label;
}
function tryParseWithShape(
- input: string,
- shape: { open: string; close: string; type: string; shape: NodeData['shape'] }
+ input: string,
+ shape: { open: string; close: string; type: string; shape: NodeData['shape'] }
): RawNode | null {
- const openIndex = input.indexOf(shape.open);
- if (openIndex < 1) return null;
- if (openIndex > 0 && input[openIndex - 1] === shape.open[0]) return null;
-
- const id = input.substring(0, openIndex).trim();
- if (!/^[a-zA-Z0-9_][\w-]*$/.test(id)) return null;
-
- const afterOpen = input.substring(openIndex + shape.open.length);
- const closeIndex = afterOpen.lastIndexOf(shape.close);
- if (closeIndex < 0) return null;
-
- const afterClose = afterOpen.substring(closeIndex + shape.close.length).trim();
- let classes: string[] = [];
- if (afterClose.startsWith(':::')) {
- classes = afterClose.substring(3).split(/,\s*/);
- } else if (afterClose) {
- return null;
- }
+ const openIndex = input.indexOf(shape.open);
+ if (openIndex < 1) return null;
+ if (openIndex > 0 && input[openIndex - 1] === shape.open[0]) return null;
+
+ const id = input.substring(0, openIndex).trim();
+ if (!MERMAID_NODE_ID_RE.test(id)) return null;
+
+ const afterOpen = input.substring(openIndex + shape.open.length);
+ const closeIndex = afterOpen.lastIndexOf(shape.close);
+ if (closeIndex < 0) return null;
+
+ const afterClose = afterOpen.substring(closeIndex + shape.close.length).trim();
+ let classes: string[] = [];
+ if (afterClose.startsWith(':::')) {
+ classes = afterClose.substring(3).split(/,\s*/);
+ } else if (afterClose) {
+ return null;
+ }
+
+ let label = afterOpen.substring(0, closeIndex).trim();
+ if (
+ (label.startsWith('"') && label.endsWith('"')) ||
+ (label.startsWith("'") && label.endsWith("'"))
+ ) {
+ label = label.slice(1, -1);
+ }
+ label = label.replace(/\\n/g, '\n');
+ label = stripFaIcons(label);
+ label = stripMarkdown(label);
+ if (!label) label = id;
+
+ return {
+ id,
+ label,
+ type: shape.type,
+ shape: shape.shape,
+ classes: classes.length ? classes : undefined,
+ };
+}
- let label = afterOpen.substring(0, closeIndex).trim();
- if ((label.startsWith('"') && label.endsWith('"')) || (label.startsWith("'") && label.endsWith("'"))) {
- label = label.slice(1, -1);
+export function parseNodeDeclaration(raw: string): RawNode | null {
+ const trimmed = raw.trim().replace(/;$/, '');
+ if (!trimmed) return null;
+
+ const annotation = extractModernAnnotation(trimmed);
+ const input = annotation.cleanInput;
+
+ for (const shape of SHAPE_OPENERS) {
+ const result = tryParseWithShape(input, shape);
+ if (result) {
+ if (annotation.shapeKey && MODERN_SHAPE_MAP[annotation.shapeKey]) {
+ const override = MODERN_SHAPE_MAP[annotation.shapeKey];
+ result.type = override.type;
+ result.shape = override.shape;
+ }
+ if (annotation.labelOverride) {
+ result.label = annotation.labelOverride;
+ }
+ result.label = stripMarkdown(result.label);
+ return result;
}
- label = label.replace(/\\n/g, '\n');
- label = stripFaIcons(label);
- if (!label) label = id;
+ }
+
+ let id = input;
+ let classes: string[] = [];
+ if (id.includes(':::')) {
+ const parts = id.split(':::');
+ id = parts[0];
+ classes = parts[1].split(/,\s*/);
+ }
+
+ if (MERMAID_NODE_ID_RE.test(id)) {
+ const override = annotation.shapeKey ? MODERN_SHAPE_MAP[annotation.shapeKey] : undefined;
+
+ return {
+ id,
+ label: stripMarkdown(annotation.labelOverride ?? id),
+ type: override?.type ?? 'process',
+ shape: override?.shape,
+ classes: classes.length ? classes : undefined,
+ };
+ }
+
+ return null;
+}
+
+export const ARROW_PATTERNS = [
+ '<==>',
+ '<-.->',
+ '<-->',
+ '<==',
+ '<-.',
+ '<--',
+ '===>',
+ '-.->',
+ '--->',
+ '-->',
+ '===',
+ '---',
+ '==>',
+ '-.-',
+ '--',
+];
- return { id, label, type: shape.type, shape: shape.shape, classes: classes.length ? classes : undefined };
+function sanitizeEdgeEndpoint(raw: string): string {
+ return raw.trim().replace(/;$/, '').trim();
}
-export function parseNodeDeclaration(raw: string): RawNode | null {
- const trimmed = raw.trim();
- if (!trimmed) return null;
+function findArrowInLine(
+ line: string
+): { arrow: string; index: number; before: string; after: string } | null {
+ let quoteChar: '"' | "'" | null = null;
+ let pipeOpen = false;
+ let squareDepth = 0;
+ let roundDepth = 0;
+ let curlyDepth = 0;
+
+ for (let index = 0; index < line.length; index++) {
+ const char = line[index];
+ const previousChar = line[index - 1];
+
+ if (quoteChar) {
+ if (char === quoteChar && previousChar !== '\\') {
+ quoteChar = null;
+ }
+ continue;
+ }
- for (const shape of SHAPE_OPENERS) {
- const result = tryParseWithShape(trimmed, shape);
- if (result) return result;
+ if (char === '"' || char === "'") {
+ quoteChar = char;
+ continue;
}
- let id = trimmed;
- let classes: string[] = [];
- if (id.includes(':::')) {
- const parts = id.split(':::');
- id = parts[0];
- classes = parts[1].split(/,\s*/);
+ if (char === '|') {
+ pipeOpen = !pipeOpen;
+ continue;
+ }
+ if (pipeOpen) {
+ continue;
}
- if (/^[a-zA-Z0-9_][\w-]*$/.test(id)) {
- return { id, label: id, type: 'process', classes: classes.length ? classes : undefined };
+ if (char === '[') {
+ squareDepth += 1;
+ continue;
+ }
+ if (char === ']') {
+ squareDepth = Math.max(0, squareDepth - 1);
+ continue;
+ }
+ if (char === '(') {
+ roundDepth += 1;
+ continue;
+ }
+ if (char === ')') {
+ roundDepth = Math.max(0, roundDepth - 1);
+ continue;
+ }
+ if (char === '{') {
+ curlyDepth += 1;
+ continue;
+ }
+ if (char === '}') {
+ curlyDepth = Math.max(0, curlyDepth - 1);
+ continue;
}
- return null;
+ if (squareDepth > 0 || roundDepth > 0 || curlyDepth > 0) {
+ continue;
+ }
+
+ for (const arrow of ARROW_PATTERNS) {
+ if (line.startsWith(arrow, index)) {
+ return {
+ arrow,
+ index,
+ before: line.substring(0, index).trim(),
+ after: line.substring(index + arrow.length).trim(),
+ };
+ }
+ }
+ }
+
+ return null;
}
-export const ARROW_PATTERNS = [
- '<==>',
- '<-.->',
- '<-->',
- '<==',
- '<-.',
- '<--',
- '===>',
- '-.->',
- '--->',
- '-->',
- '===',
- '---',
- '==>',
- '-.-',
- '--',
-];
+function parseEdgeLabelSegment(
+ line: string,
+ startIndex: number
+): { label: string; nextIndex: number } {
+ let index = startIndex;
+ while (index < line.length && /\s/.test(line[index])) {
+ index += 1;
+ }
+
+ if (line[index] !== '|') {
+ return { label: '', nextIndex: index };
+ }
+
+ let label = '';
+ let quoteChar: '"' | "'" | null = null;
+ index += 1;
+
+ while (index < line.length) {
+ const char = line[index];
+ const previousChar = line[index - 1];
+
+ if (quoteChar) {
+ if (char === quoteChar && previousChar !== '\\') {
+ quoteChar = null;
+ } else {
+ label += char;
+ }
+ index += 1;
+ continue;
+ }
-function findArrowInLine(line: string): { arrow: string; before: string; after: string } | null {
- for (const arrow of ARROW_PATTERNS) {
- const index = line.indexOf(arrow);
- if (index >= 0) {
- return {
- arrow,
- before: line.substring(0, index).trim(),
- after: line.substring(index + arrow.length).trim(),
- };
- }
+ if (char === '"' || char === "'") {
+ quoteChar = char;
+ index += 1;
+ continue;
}
- return null;
+
+ if (char === '|') {
+ return { label: label.trim(), nextIndex: index + 1 };
+ }
+
+ label += char;
+ index += 1;
+ }
+
+ return { label: label.trim(), nextIndex: index };
+}
+
+function expandAmpersandEdges(line: string): string[] {
+ if (!line.includes('&')) return [line];
+ const arrowMatch = findArrowInLine(line);
+ if (!arrowMatch) return [line];
+
+ const { arrow, index } = arrowMatch;
+ const sourcePart = line.substring(0, index).trim();
+ const afterArrow = line.substring(index + arrow.length).trim();
+
+ // Parse optional pipe label after arrow
+ const labelMatch = afterArrow.match(/^\|([^|]*)\|(.*)/);
+ const label = labelMatch ? `|${labelMatch[1]}|` : '';
+ const targetPart = labelMatch ? labelMatch[2].trim() : afterArrow;
+
+ const sources = sourcePart.split('&').map((s) => s.trim()).filter(Boolean);
+ const targets = targetPart.split('&').map((s) => s.trim()).filter(Boolean);
+
+ if (sources.length <= 1 && targets.length <= 1) return [line];
+
+ const lines: string[] = [];
+ for (const src of sources) {
+ for (const tgt of targets) {
+ lines.push(`${src} ${arrow}${label} ${tgt}`);
+ }
+ }
+ return lines;
}
export function parseEdgeLine(line: string): Array<{
- sourceRaw: string;
- targetRaw: string;
- label: string;
- arrowType: string;
+ sourceRaw: string;
+ targetRaw: string;
+ label: string;
+ arrowType: string;
}> {
- const edges: Array<{ sourceRaw: string; targetRaw: string; label: string; arrowType: string }> = [];
- let remaining = line;
- let lastNodeRaw: string | null = null;
-
- while (remaining.trim()) {
- const arrowMatch = findArrowInLine(remaining);
- if (!arrowMatch) break;
-
- const { arrow, before, after } = arrowMatch;
- const sourceRaw = lastNodeRaw || before;
- let label = '';
- let targetAndRest = after;
-
- const labelMatch = targetAndRest.match(/^\|"?([^"|]*)"?\|\s*/);
- if (labelMatch) {
- label = labelMatch[1].trim();
- targetAndRest = targetAndRest.substring(labelMatch[0].length);
- }
-
- const nextArrowMatch = findArrowInLine(targetAndRest);
- let targetRaw: string;
-
- if (nextArrowMatch) {
- targetRaw = nextArrowMatch.before;
- remaining = targetAndRest;
- } else {
- targetRaw = targetAndRest;
- remaining = '';
- }
-
- let source = sourceRaw.trim();
- let target = targetRaw.trim();
-
- if (source.includes(':::')) source = source.split(':::')[0];
- if (target.includes(':::')) target = target.split(':::')[0];
-
- if (source && target) {
- edges.push({ sourceRaw: source, targetRaw: target, label, arrowType: arrow });
- }
-
- lastNodeRaw = targetRaw.trim();
- if (!nextArrowMatch) break;
+ const expanded = expandAmpersandEdges(line);
+ if (expanded.length > 1) {
+ return expanded.flatMap(parseEdgeLine);
+ }
+
+ const edges: Array<{ sourceRaw: string; targetRaw: string; label: string; arrowType: string }> =
+ [];
+ let remaining = line.trim();
+ let lastNodeRaw: string | null = null;
+
+ while (remaining.trim()) {
+ const arrowMatch = findArrowInLine(remaining);
+ if (!arrowMatch) break;
+
+ const { arrow } = arrowMatch;
+ const sourceRaw = sanitizeEdgeEndpoint(lastNodeRaw || arrowMatch.before);
+ const sourceOffset = arrowMatch.index + arrow.length;
+ const { label, nextIndex } = parseEdgeLabelSegment(remaining, sourceOffset);
+ const targetSegment = remaining.slice(nextIndex).trim();
+ const nextArrowMatch = findArrowInLine(targetSegment);
+ const targetRaw = sanitizeEdgeEndpoint(
+ nextArrowMatch ? targetSegment.slice(0, nextArrowMatch.index) : targetSegment
+ );
+
+ if (sourceRaw && targetRaw) {
+ edges.push({ sourceRaw, targetRaw, label, arrowType: arrow });
}
- return edges;
+ lastNodeRaw = targetRaw;
+ remaining = nextArrowMatch ? targetSegment.slice(nextArrowMatch.index) : '';
+ if (!nextArrowMatch) break;
+ }
+
+ return edges;
}
export function parseStyleString(styleStr: string): Record {
- const styles: Record = {};
- const parts = styleStr.split(',');
-
- for (const part of parts) {
- const [key, value] = part.split(':').map((s) => s.trim());
- if (key && value) {
- styles[key] = value.replace(/;$/, '');
- }
+ const styles: Record = {};
+ const parts = styleStr.split(',');
+
+ for (const part of parts) {
+ const [key, value] = part.split(':').map((s) => s.trim());
+ if (key && value) {
+ styles[key] = value.replace(/;$/, '');
}
+ }
- return styles;
+ return styles;
}
diff --git a/src/lib/mermaidParserModel.ts b/src/lib/mermaidParserModel.ts
index 0a08ed12..ee5d3e1d 100644
--- a/src/lib/mermaidParserModel.ts
+++ b/src/lib/mermaidParserModel.ts
@@ -15,6 +15,7 @@ export interface MermaidParseModel {
rawEdges: MermaidRawEdge[];
linkStyles: Map>;
classDefs: Map>;
+ diagnostics: string[];
direction: MermaidDirection;
diagramType: MermaidDiagramType;
}
@@ -24,6 +25,7 @@ export interface MermaidParseState {
rawEdges: MermaidRawEdge[];
linkStyles: Map>;
classDefs: Map>;
+ diagnostics: string[];
direction: MermaidDirection;
diagramType: MermaidDiagramType;
parentStack: string[];
@@ -36,6 +38,7 @@ export function createMermaidParseState(): MermaidParseState {
rawEdges: [],
linkStyles: new Map>(),
classDefs: new Map>(),
+ diagnostics: [],
direction: 'TB',
diagramType: 'unknown',
parentStack: [],
@@ -115,6 +118,7 @@ export function toMermaidParseModel(state: MermaidParseState): MermaidParseModel
rawEdges: state.rawEdges,
linkStyles: state.linkStyles,
classDefs: state.classDefs,
+ diagnostics: state.diagnostics,
direction: state.direction,
diagramType: state.diagramType,
};
diff --git a/src/lib/nodeEnricher.test.ts b/src/lib/nodeEnricher.test.ts
new file mode 100644
index 00000000..69ef4e48
--- /dev/null
+++ b/src/lib/nodeEnricher.test.ts
@@ -0,0 +1,241 @@
+import { describe, expect, it } from 'vitest';
+import { enrichNodesWithIcons } from './nodeEnricher';
+import type { FlowNode } from './types';
+
+function makeNode(id: string, label: string, overrides?: Partial): FlowNode {
+ return {
+ id,
+ type: 'process',
+ position: { x: 0, y: 0 },
+ data: { label, color: 'slate' },
+ ...overrides,
+ } as FlowNode;
+}
+
+describe('enrichNodesWithIcons', () => {
+ it('assigns color based on semantic classification', async () => {
+ const nodes = [
+ makeNode('start', 'Start'),
+ makeNode('end', 'End'),
+ makeNode('db', 'PostgreSQL'),
+ makeNode('check', 'Is Valid?', {
+ data: { label: 'Is Valid?', color: 'slate', shape: 'diamond' },
+ }),
+ ];
+
+ const enriched = await enrichNodesWithIcons(nodes);
+
+ expect(enriched[0].data.color).toBe('emerald');
+ expect(enriched[1].data.color).toBe('red');
+ expect(enriched[2].data.color).toBe('violet');
+ expect(enriched[3].data.color).toBe('amber');
+ });
+
+ it('assigns icons for known technologies', async () => {
+ const nodes = [
+ makeNode('db', 'PostgreSQL'),
+ makeNode('cache', 'Redis Cache'),
+ makeNode('api', 'Express API'),
+ ];
+
+ const enriched = await enrichNodesWithIcons(nodes);
+
+ // All three should get provider icons (any catalog)
+ expect(enriched[0].data.archIconPackId).toBeTruthy();
+ expect(enriched[0].data.archIconShapeId).toContain('postgresql');
+ expect(enriched[0].data.assetProvider).toBe('developer');
+
+ expect(enriched[1].data.archIconPackId).toBeTruthy();
+ expect(enriched[1].data.archIconShapeId).toContain('redis');
+ expect(enriched[1].data.assetProvider).toBe('developer');
+
+ expect(enriched[2].data.archIconPackId).toBeTruthy();
+ expect(enriched[2].data.assetProvider).toBeTruthy();
+ });
+
+ it('skips section and group nodes', async () => {
+ const nodes = [
+ { ...makeNode('grp', 'Group'), type: 'section' as const },
+ makeNode('x', 'Something'),
+ ];
+
+ const enriched = await enrichNodesWithIcons(nodes);
+
+ expect(enriched[0].data.color).toBe('slate');
+ expect(enriched[0].data.icon).toBeUndefined();
+ });
+
+ it('preserves existing non-slate colors', async () => {
+ const nodes = [
+ {
+ id: 'a',
+ type: 'process',
+ position: { x: 0, y: 0 },
+ data: { label: 'Start', color: 'pink' },
+ },
+ ];
+
+ const enriched = await enrichNodesWithIcons(nodes as FlowNode[]);
+
+ expect(enriched[0].data.color).toBe('pink');
+ });
+
+ it('preserves existing icons', async () => {
+ const nodes = [
+ {
+ id: 'a',
+ type: 'process',
+ position: { x: 0, y: 0 },
+ data: { label: 'PostgreSQL', color: 'violet', icon: 'my-icon' },
+ },
+ ];
+
+ const enriched = await enrichNodesWithIcons(nodes as FlowNode[]);
+
+ expect(enriched[0].data.icon).toBe('my-icon');
+ });
+
+ it('handles empty node array', async () => {
+ const enriched = await enrichNodesWithIcons([]);
+ expect(enriched).toEqual([]);
+ });
+
+ it('preserves nodes with no changes', async () => {
+ const nodes = [
+ {
+ id: 'a',
+ type: 'process',
+ position: { x: 0, y: 0 },
+ data: { label: 'Something Random', color: 'blue', icon: 'Box' },
+ },
+ ];
+
+ const enriched = await enrichNodesWithIcons(nodes as FlowNode[]);
+ expect(enriched[0]).toEqual(nodes[0]);
+ });
+
+ it('classifies decision shape correctly', async () => {
+ const nodes = [
+ makeNode('check', 'Validate?', {
+ data: { label: 'Validate?', color: 'slate', shape: 'diamond' },
+ }),
+ ];
+
+ const enriched = await enrichNodesWithIcons(nodes);
+ expect(enriched[0].data.color).toBe('amber');
+ });
+
+ it('classifies cylinder shape as database', async () => {
+ const nodes = [
+ makeNode('pg', 'PostgreSQL DB', {
+ data: { label: 'PostgreSQL DB', color: 'slate', shape: 'cylinder' },
+ }),
+ ];
+
+ const enriched = await enrichNodesWithIcons(nodes);
+ expect(enriched[0].data.color).toBe('violet');
+ if (!enriched[0].data.archIconPackId) {
+ expect(enriched[0].data.icon).toBe('database');
+ }
+ });
+
+ it('uses icon attribute for explicit catalog search', async () => {
+ const nodes = [
+ makeNode('cache', 'My Cache', {
+ data: { label: 'My Cache', color: 'slate', icon: 'redis' },
+ }),
+ ];
+
+ const enriched = await enrichNodesWithIcons(nodes);
+ expect(enriched[0].data.archIconPackId).toBeTruthy();
+ expect(enriched[0].data.archIconShapeId).toContain('redis');
+ expect(enriched[0].data.assetProvider).toBe('developer');
+ });
+
+ it('uses provider filter when set', async () => {
+ const nodes = [
+ makeNode('db', 'Database', {
+ data: { label: 'Database', color: 'slate', provider: 'aws' },
+ }),
+ ];
+
+ const enriched = await enrichNodesWithIcons(nodes);
+ if (enriched[0].data.archIconPackId) {
+ expect(enriched[0].data.archIconPackId).toBe('aws-official-starter-v1');
+ expect(enriched[0].data.assetProvider).toBe('aws');
+ }
+ });
+
+ it('disables aggressive label-based icon enrichment during mermaid import', async () => {
+ const nodes = [makeNode('payment', 'Payment Processing Flow')];
+
+ const enriched = await enrichNodesWithIcons(nodes, {
+ diagramType: 'flowchart',
+ mode: 'mermaid-import',
+ });
+
+ expect(enriched[0].data.archIconPackId).toBeUndefined();
+ });
+
+ it('keeps technology icon enrichment enabled for flowchart imports', async () => {
+ const nodes = [
+ makeNode('db', 'PostgreSQL'),
+ makeNode('cache', 'Redis Cache'),
+ makeNode('queue', 'Kafka'),
+ makeNode('proxy', 'Nginx'),
+ makeNode('app', 'React App'),
+ makeNode('bucket', 'Amazon S3'),
+ makeNode('fn', 'AWS Lambda'),
+ ];
+
+ const enriched = await enrichNodesWithIcons(nodes, {
+ diagramType: 'flowchart',
+ mode: 'mermaid-import',
+ });
+
+ expect(enriched[0].data.archIconShapeId).toContain('postgresql');
+ expect(enriched[1].data.archIconShapeId).toContain('redis');
+ expect(enriched[2].data.archIconPackId).toBeTruthy();
+ expect(enriched[3].data.archIconPackId).toBeTruthy();
+ expect(enriched[4].data.archIconPackId).toBeTruthy();
+ expect(enriched[5].data.archIconPackId).toBeTruthy();
+ expect(enriched[6].data.archIconPackId).toBeTruthy();
+ });
+
+ it('skips import-time icon enrichment for diagram families with specialized visuals', async () => {
+ const nodes = [makeNode('idle', 'Idle')];
+
+ const enriched = await enrichNodesWithIcons(nodes, {
+ diagramType: 'stateDiagram',
+ mode: 'mermaid-import',
+ });
+
+ expect(enriched[0].data.archIconPackId).toBeUndefined();
+ expect(enriched[0].data.icon).toBeUndefined();
+ });
+
+ it('keeps ambiguous flowchart imports iconless', async () => {
+ const nodes = [
+ makeNode('payment', 'Payment Processing Flow'),
+ makeNode('check', 'Check Conditions'),
+ makeNode('frontend', 'Frontend'),
+ makeNode('auth', 'Auth'),
+ makeNode('service', 'Service'),
+ makeNode('gateway', 'API Gateway'),
+ makeNode('oauth', 'OAuth'),
+ makeNode('jwt', 'JWT'),
+ makeNode('sso', 'SSO'),
+ makeNode('llm', 'LLM Router'),
+ ];
+
+ const enriched = await enrichNodesWithIcons(nodes, {
+ diagramType: 'flowchart',
+ mode: 'mermaid-import',
+ });
+
+ for (const node of enriched) {
+ expect(node.data.archIconPackId).toBeUndefined();
+ expect(node.data.assetProvider).toBeUndefined();
+ }
+ });
+});
diff --git a/src/lib/nodeEnricher.ts b/src/lib/nodeEnricher.ts
new file mode 100644
index 00000000..c54449b8
--- /dev/null
+++ b/src/lib/nodeEnricher.ts
@@ -0,0 +1,266 @@
+import type { DiagramType, FlowNode } from '@/lib/types';
+import type { DomainLibraryCategory } from '@/services/domainLibrary';
+import { createProviderIconData, normalizeNodeIconData } from '@/lib/nodeIconState';
+import {
+ classifyNode,
+ isCommonEnglishIconTerm,
+ isSpecificTechnologyIconQuery,
+} from '@/lib/semanticClassifier';
+import { matchIcon, type IconMatch } from '@/lib/iconMatcher';
+
+export interface EnrichNodesWithIconsOptions {
+ diagramType?: DiagramType;
+ mode?: 'general' | 'mermaid-import';
+}
+
+const IMPORT_ICON_MATCH_THRESHOLD = 0.92;
+const DEFAULT_ICON_MATCH_THRESHOLD = 0.8;
+const DIAGRAM_TYPES_WITHOUT_IMPORT_ICON_ENRICHMENT = new Set([
+ 'stateDiagram',
+ 'sequence',
+ 'classDiagram',
+ 'erDiagram',
+ 'journey',
+]);
+
+function withNormalizedNodeData(
+ node: FlowNode,
+ dataOverrides?: Record
+): FlowNode {
+ return {
+ ...node,
+ data: normalizeNodeIconData({
+ ...node.data,
+ ...dataOverrides,
+ }),
+ };
+}
+
+function applyProviderIcon(match: IconMatch, updates: Record): void {
+ Object.assign(
+ updates,
+ createProviderIconData({
+ packId: match.packId,
+ shapeId: match.shapeId,
+ provider: match.provider as DomainLibraryCategory,
+ category: match.category,
+ })
+ );
+ updates.assetPresentation = 'icon';
+}
+
+function isTrustedImportMatch(match: IconMatch, query: string): boolean {
+ if (match.isVariant) {
+ return false;
+ }
+
+ if (match.matchType === 'exact' || match.matchType === 'alias') {
+ return true;
+ }
+
+ if (match.matchType !== 'substring') {
+ return false;
+ }
+
+ return (
+ match.confidence === 'high'
+ && match.wholeTokenMatch
+ && !match.isGeneric
+ && !isCommonEnglishIconTerm(query)
+ && match.runnerUpDelta >= 0.08
+ );
+}
+
+export function enrichNodesWithIcons(
+ nodes: FlowNode[],
+ options: EnrichNodesWithIconsOptions = {}
+): FlowNode[] {
+ return nodes.map((node) => {
+ try {
+ return enrichSingleNode(node, options);
+ } catch {
+ return node;
+ }
+ });
+}
+
+function enrichSingleNode(node: FlowNode, options: EnrichNodesWithIconsOptions): FlowNode {
+ if (node.type === 'section' || node.type === 'group' || node.type === 'swimlane') {
+ return node;
+ }
+
+ const label = node.data?.label ?? '';
+ const nodeColor = node.data?.color;
+ const isDefaultColor = !nodeColor || nodeColor === 'slate' || nodeColor === 'white';
+ const hasExplicitColor = !isDefaultColor;
+ const hasExplicitProviderIcon = Boolean(node.data?.archIconPackId);
+ const hasAnyIcon = Boolean(node.data?.icon) || hasExplicitProviderIcon;
+
+ if (hasExplicitColor && hasAnyIcon) {
+ return withNormalizedNodeData(node);
+ }
+
+ const hint = classifyNode({ id: node.id, label, shape: node.data?.shape });
+ const dataUpdates: Record = {};
+
+ if (!hasExplicitColor) {
+ applyColor(node, hint.color, dataUpdates);
+ }
+
+ if (!hasExplicitProviderIcon) {
+ applyIcon(node, label, hint, dataUpdates, options);
+ }
+
+ if (Object.keys(dataUpdates).length === 0) {
+ return withNormalizedNodeData(node);
+ }
+
+ return withNormalizedNodeData(node, dataUpdates);
+}
+
+function applyColor(
+ node: FlowNode,
+ classifierColor: string,
+ updates: Record
+): void {
+ if (node.type === 'start') {
+ updates.color = 'emerald';
+ } else if (node.type === 'end') {
+ updates.color = 'red';
+ } else if (node.type === 'decision') {
+ updates.color = 'amber';
+ } else {
+ updates.color = classifierColor;
+ }
+}
+
+function applyIcon(
+ node: FlowNode,
+ label: string,
+ hint: { iconQuery: string; lucideFallback: string; category: string },
+ updates: Record,
+ options: EnrichNodesWithIconsOptions
+): void {
+ if (node.type === 'start' || node.type === 'end' || node.type === 'decision') {
+ applyLucideFallback(node, hint.lucideFallback, updates);
+ return;
+ }
+
+ const explicitIcon = node.data?.icon;
+ const provider = node.data?.provider;
+ const providerHint = typeof provider === 'string' ? provider : undefined;
+ const { iconMatchThreshold, iconEnrichmentAllowed } = getIconEnrichmentPolicy(options);
+
+ if (explicitIcon && typeof explicitIcon === 'string' && explicitIcon !== 'none') {
+ const match = findBestMatch(explicitIcon, providerHint, iconMatchThreshold, options);
+ if (match) {
+ applyProviderIcon(match, updates);
+ }
+ return;
+ }
+
+ if (!iconEnrichmentAllowed) {
+ applyLucideFallback(node, hint.lucideFallback, updates);
+ return;
+ }
+
+ if (hint.iconQuery && shouldUseClassifierIconQuery(hint.iconQuery, options)) {
+ const match = findBestMatch(hint.iconQuery, providerHint, iconMatchThreshold, options);
+ if (match) {
+ applyProviderIcon(match, updates);
+ return;
+ }
+ }
+
+ if (label && !node.data?.icon && shouldUseLabelFallback(label, options)) {
+ const match = findBestMatch(label, providerHint, iconMatchThreshold, options);
+ if (match) {
+ applyProviderIcon(match, updates);
+ }
+ }
+
+ // Lucide icon fallback โ only in non-import mode, or for structural node types
+ if (!options.mode || options.mode !== 'mermaid-import') {
+ applyLucideFallback(node, hint.lucideFallback, updates);
+ }
+}
+
+function getIconEnrichmentPolicy(options: EnrichNodesWithIconsOptions): {
+ iconMatchThreshold: number;
+ iconEnrichmentAllowed: boolean;
+} {
+ const strictImportMode = options.mode === 'mermaid-import';
+ const iconMatchThreshold = strictImportMode
+ ? IMPORT_ICON_MATCH_THRESHOLD
+ : DEFAULT_ICON_MATCH_THRESHOLD;
+ const iconEnrichmentAllowed =
+ !strictImportMode
+ || !options.diagramType
+ || !DIAGRAM_TYPES_WITHOUT_IMPORT_ICON_ENRICHMENT.has(options.diagramType);
+
+ return {
+ iconMatchThreshold,
+ iconEnrichmentAllowed,
+ };
+}
+
+function applyLucideFallback(
+ node: FlowNode,
+ lucideFallback: string,
+ updates: Record
+): void {
+ if (lucideFallback && lucideFallback !== 'box') {
+ updates.icon = lucideFallback;
+ } else if (node.type === 'start') {
+ updates.icon = 'play';
+ } else if (node.type === 'end') {
+ updates.icon = 'check-circle';
+ } else if (node.type === 'decision') {
+ updates.icon = 'help-circle';
+ }
+}
+
+function shouldUseClassifierIconQuery(
+ iconQuery: string,
+ options: EnrichNodesWithIconsOptions
+): boolean {
+ if (options.mode !== 'mermaid-import') {
+ return !isCommonEnglishIconTerm(iconQuery);
+ }
+
+ if (options.diagramType === 'flowchart') {
+ return isSpecificTechnologyIconQuery(iconQuery);
+ }
+
+ return !isCommonEnglishIconTerm(iconQuery);
+}
+
+function shouldUseLabelFallback(
+ label: string,
+ options: EnrichNodesWithIconsOptions
+): boolean {
+ if (options.mode === 'mermaid-import') {
+ return false;
+ }
+
+ return !isCommonEnglishIconTerm(label);
+}
+
+function findBestMatch(
+ query: string,
+ providerHint?: string,
+ threshold = DEFAULT_ICON_MATCH_THRESHOLD,
+ options: EnrichNodesWithIconsOptions = {}
+): IconMatch | undefined {
+ const matches = matchIcon(query, providerHint);
+ const best = matches[0];
+ if (!best || best.score < threshold) {
+ return undefined;
+ }
+
+ if (options.mode === 'mermaid-import' && !isTrustedImportMatch(best, query)) {
+ return undefined;
+ }
+
+ return best;
+}
diff --git a/src/lib/nodeIconState.test.ts b/src/lib/nodeIconState.test.ts
new file mode 100644
index 00000000..00d9962d
--- /dev/null
+++ b/src/lib/nodeIconState.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from 'vitest';
+import {
+ createBuiltInIconData,
+ createProviderIconData,
+ createUploadedIconData,
+ inferAssetProviderFromPackId,
+ normalizeNodeIconData,
+} from './nodeIconState';
+
+describe('nodeIconState', () => {
+ it('infers provider from known pack ids', () => {
+ expect(inferAssetProviderFromPackId('aws-official-starter-v1')).toBe('aws');
+ expect(inferAssetProviderFromPackId('developer-icons-v1')).toBe('developer');
+ });
+
+ it('normalizes pack and shape to a canonical provider icon payload', () => {
+ expect(
+ normalizeNodeIconData({
+ archIconPackId: 'aws-official-starter-v1',
+ archIconShapeId: 'compute-lambda',
+ })
+ ).toMatchObject({
+ archIconPackId: 'aws-official-starter-v1',
+ archIconShapeId: 'compute-lambda',
+ assetProvider: 'aws',
+ });
+ });
+
+ it('createBuiltInIconData clears provider and upload fields', () => {
+ expect(createBuiltInIconData('Database')).toEqual({
+ icon: 'Database',
+ customIconUrl: undefined,
+ assetProvider: undefined,
+ assetCategory: undefined,
+ archIconPackId: undefined,
+ archIconShapeId: undefined,
+ });
+ });
+
+ it('createProviderIconData clears built-in and upload fields', () => {
+ expect(
+ createProviderIconData({
+ packId: 'aws-official-starter-v1',
+ shapeId: 'compute-lambda',
+ provider: 'aws',
+ category: 'Compute',
+ })
+ ).toEqual({
+ icon: undefined,
+ customIconUrl: undefined,
+ archIconPackId: 'aws-official-starter-v1',
+ archIconShapeId: 'compute-lambda',
+ assetProvider: 'aws',
+ assetCategory: 'Compute',
+ });
+ });
+
+ it('createUploadedIconData clears built-in and provider fields', () => {
+ expect(createUploadedIconData('data:image/svg+xml;base64,abc')).toEqual({
+ icon: undefined,
+ customIconUrl: 'data:image/svg+xml;base64,abc',
+ assetProvider: undefined,
+ assetCategory: undefined,
+ archIconPackId: undefined,
+ archIconShapeId: undefined,
+ });
+ });
+});
diff --git a/src/lib/nodeIconState.ts b/src/lib/nodeIconState.ts
new file mode 100644
index 00000000..5b38d421
--- /dev/null
+++ b/src/lib/nodeIconState.ts
@@ -0,0 +1,146 @@
+import type { NodeData } from '@/lib/types';
+import type { DomainLibraryCategory } from '@/services/domainLibrary';
+import { KNOWN_PROVIDER_PACK_IDS, SVG_SOURCES } from '@/services/shapeLibrary/providerCatalog';
+
+export interface ResolvedProviderIconMetadata {
+ provider?: DomainLibraryCategory;
+ category?: string;
+ label?: string;
+}
+
+const PACK_ID_TO_PROVIDER = new Map(
+ Object.entries(KNOWN_PROVIDER_PACK_IDS).map(([provider, packId]) => [
+ packId.toLowerCase(),
+ provider as DomainLibraryCategory,
+ ])
+);
+
+const SHAPE_METADATA = new Map(
+ SVG_SOURCES.map((source) => [
+ `${source.packId}:${source.shapeId}`,
+ {
+ provider: source.provider as DomainLibraryCategory,
+ category: source.category,
+ label: source.label,
+ },
+ ])
+);
+
+function isNonEmptyString(value: unknown): value is string {
+ return typeof value === 'string' && value.trim().length > 0;
+}
+
+export function inferAssetProviderFromPackId(
+ packId: string | undefined
+): DomainLibraryCategory | undefined {
+ if (!isNonEmptyString(packId)) {
+ return undefined;
+ }
+
+ const normalizedPackId = packId.trim().toLowerCase();
+ const exactMatch = PACK_ID_TO_PROVIDER.get(normalizedPackId);
+ if (exactMatch) {
+ return exactMatch;
+ }
+
+ const prefixMatch = Array.from(PACK_ID_TO_PROVIDER.entries()).find(([, provider]) =>
+ normalizedPackId.includes(provider)
+ );
+ return prefixMatch?.[1];
+}
+
+export function getProviderIconMetadata(
+ packId: string | undefined,
+ shapeId: string | undefined
+): ResolvedProviderIconMetadata {
+ if (!isNonEmptyString(packId) || !isNonEmptyString(shapeId)) {
+ return {};
+ }
+
+ return SHAPE_METADATA.get(`${packId}:${shapeId}`) ?? {
+ provider: inferAssetProviderFromPackId(packId),
+ };
+}
+
+export function createBuiltInIconData(icon: string): Partial {
+ return {
+ icon,
+ customIconUrl: undefined,
+ assetProvider: undefined,
+ assetCategory: undefined,
+ archIconPackId: undefined,
+ archIconShapeId: undefined,
+ };
+}
+
+export function createUploadedIconData(url?: string): Partial {
+ return {
+ icon: undefined,
+ customIconUrl: url,
+ assetProvider: undefined,
+ assetCategory: undefined,
+ archIconPackId: undefined,
+ archIconShapeId: undefined,
+ };
+}
+
+export function createProviderIconData(input: {
+ packId: string;
+ shapeId: string;
+ provider?: DomainLibraryCategory;
+ category?: string;
+}): Partial {
+ const resolved = getProviderIconMetadata(input.packId, input.shapeId);
+
+ return {
+ icon: undefined,
+ customIconUrl: undefined,
+ archIconPackId: input.packId,
+ archIconShapeId: input.shapeId,
+ assetProvider: input.provider ?? resolved.provider,
+ assetCategory: input.category ?? resolved.category,
+ };
+}
+
+export function normalizeNodeIconData | undefined>(data: T): T {
+ if (!data) {
+ return data;
+ }
+
+ const next: Partial = { ...data };
+ const hasProviderIcon =
+ isNonEmptyString(next.archIconPackId) && isNonEmptyString(next.archIconShapeId);
+ const hasUploadIcon = isNonEmptyString(next.customIconUrl);
+ const hasBuiltInIcon = isNonEmptyString(next.icon);
+
+ if (hasProviderIcon) {
+ Object.assign(
+ next,
+ createProviderIconData({
+ packId: next.archIconPackId as string,
+ shapeId: next.archIconShapeId as string,
+ provider: next.assetProvider as DomainLibraryCategory | undefined,
+ category: next.assetCategory as string | undefined,
+ })
+ );
+ return next as T;
+ }
+
+ if (hasUploadIcon) {
+ Object.assign(next, createUploadedIconData(next.customIconUrl as string));
+ return next as T;
+ }
+
+ if (hasBuiltInIcon) {
+ Object.assign(next, createBuiltInIconData(next.icon as string));
+ return next as T;
+ }
+
+ next.icon = undefined;
+ next.customIconUrl = undefined;
+ next.assetProvider = undefined;
+ next.assetCategory = undefined;
+ next.archIconPackId = undefined;
+ next.archIconShapeId = undefined;
+ return next as T;
+}
diff --git a/src/lib/nodeParent.ts b/src/lib/nodeParent.ts
index 1bb52ae6..b3cd66c0 100644
--- a/src/lib/nodeParent.ts
+++ b/src/lib/nodeParent.ts
@@ -5,6 +5,10 @@ type NodeWithParent = Node & {
extent?: Node['extent'];
};
+interface SetNodeParentOptions {
+ constrainToParent?: boolean;
+}
+
export function getNodeParentId(node: NodeWithParent): string {
if (typeof node.parentId === 'string' && node.parentId.length > 0) {
return node.parentId;
@@ -12,12 +16,23 @@ export function getNodeParentId(node: NodeWithParent): string {
return '';
}
-export function setNodeParent(node: T, parentId: string): T {
- return {
+export function setNodeParent(
+ node: T,
+ parentId: string,
+ options: SetNodeParentOptions = {}
+): T {
+ const nextNode = {
...node,
parentId,
- extent: 'parent' as const,
- } as T;
+ } as NodeWithParent;
+
+ if (options.constrainToParent) {
+ nextNode.extent = 'parent';
+ } else {
+ delete nextNode.extent;
+ }
+
+ return nextNode as T;
}
export function clearNodeParent(node: T): T {
diff --git a/src/lib/semanticClassifier.test.ts b/src/lib/semanticClassifier.test.ts
new file mode 100644
index 00000000..ace756af
--- /dev/null
+++ b/src/lib/semanticClassifier.test.ts
@@ -0,0 +1,127 @@
+import { describe, expect, it } from 'vitest';
+import {
+ classifyNode,
+ isCommonEnglishIconTerm,
+ isSpecificTechnologyIconQuery,
+} from './semanticClassifier';
+
+describe('classifyNode', () => {
+ it('classifies start nodes', () => {
+ expect(classifyNode({ id: 'start', label: 'Start' }).category).toBe('start');
+ expect(classifyNode({ id: 'begin', label: 'Begin' }).category).toBe('start');
+ expect(classifyNode({ id: 'entry', label: 'Entry Point' }).category).toBe('start');
+ expect(classifyNode({ id: 'x', label: 'Order Start' }).category).toBe('start');
+ });
+
+ it('classifies end nodes', () => {
+ expect(classifyNode({ id: 'end', label: 'End' }).category).toBe('end');
+ expect(classifyNode({ id: 'done', label: 'Done' }).category).toBe('end');
+ expect(classifyNode({ id: 'finish', label: 'Complete' }).category).toBe('end');
+ });
+
+ it('classifies decision nodes by shape', () => {
+ const hint = classifyNode({ id: 'check', label: 'Is Valid?', shape: 'diamond' });
+ expect(hint.category).toBe('decision');
+ expect(hint.color).toBe('amber');
+ });
+
+ it('classifies database nodes', () => {
+ const pg = classifyNode({ id: 'db', label: 'PostgreSQL' });
+ expect(pg.category).toBe('database');
+ expect(pg.color).toBe('violet');
+ expect(pg.iconQuery).toMatch(/postgres/i);
+
+ const mongo = classifyNode({ id: 'db', label: 'MongoDB' });
+ expect(mongo.category).toBe('database');
+ expect(mongo.iconQuery).toMatch(/mongo/i);
+ });
+
+ it('classifies cylinder shape as database', () => {
+ const hint = classifyNode({ id: 'db', label: 'Users DB', shape: 'cylinder' });
+ expect(hint.category).toBe('database');
+ expect(hint.color).toBe('violet');
+ });
+
+ it('classifies cache nodes', () => {
+ const hint = classifyNode({ id: 'cache', label: 'Redis Cache' });
+ expect(hint.category).toBe('cache');
+ expect(hint.iconQuery).toMatch(/redis/i);
+ });
+
+ it('classifies queue nodes', () => {
+ const hint = classifyNode({ id: 'mq', label: 'RabbitMQ' });
+ expect(hint.category).toBe('queue');
+ expect(hint.iconQuery).toMatch(/rabbitmq/i);
+ });
+
+ it('classifies user nodes', () => {
+ const hint = classifyNode({ id: 'user', label: 'User' });
+ expect(hint.category).toBe('user');
+ expect(hint.color).toBe('blue');
+ });
+
+ it('classifies gateway nodes', () => {
+ const hint = classifyNode({ id: 'gw', label: 'API Gateway' });
+ expect(hint.category).toBe('gateway');
+ expect(hint.iconQuery).toBe('');
+
+ const nginx = classifyNode({ id: 'proxy', label: 'Nginx' });
+ expect(nginx.category).toBe('gateway');
+ expect(nginx.iconQuery).toMatch(/nginx/i);
+ });
+
+ it('classifies frontend nodes', () => {
+ const hint = classifyNode({ id: 'fe', label: 'React App' });
+ expect(hint.category).toBe('frontend');
+ expect(hint.iconQuery).toMatch(/react/i);
+ });
+
+ it('classifies service nodes', () => {
+ const hint = classifyNode({ id: 'api', label: 'Express API' });
+ expect(hint.category).toBe('service');
+ expect(hint.iconQuery).toMatch(/express/i);
+
+ const node = classifyNode({ id: 'be', label: 'Node.js Backend' });
+ expect(node.category).toBe('service');
+ });
+
+ it('classifies auth nodes', () => {
+ const hint = classifyNode({ id: 'auth', label: 'OAuth Login' });
+ expect(hint.category).toBe('auth');
+ expect(hint.iconQuery).toBe('');
+ });
+
+ it('keeps generic categories color-aware but icon-query sparse', () => {
+ expect(classifyNode({ id: 'db', label: 'Database' }).iconQuery).toBe('');
+ expect(classifyNode({ id: 'queue', label: 'Queue' }).iconQuery).toBe('');
+ expect(classifyNode({ id: 'storage', label: 'Storage' }).iconQuery).toBe('');
+ expect(classifyNode({ id: 'frontend', label: 'Frontend' }).iconQuery).toBe('');
+ expect(classifyNode({ id: 'service', label: 'Service' }).iconQuery).toBe('');
+ expect(classifyNode({ id: 'llm', label: 'LLM Router' }).iconQuery).toBe('');
+ });
+
+ it('returns process as default', () => {
+ const hint = classifyNode({ id: 'x', label: 'Something Random' });
+ expect(hint.category).toBe('process');
+ expect(hint.color).toBe('slate');
+ });
+
+ it('detects generic icon terms conservatively', () => {
+ expect(isCommonEnglishIconTerm('service')).toBe(true);
+ expect(isCommonEnglishIconTerm('payment')).toBe(true);
+ expect(isCommonEnglishIconTerm('PostgreSQL')).toBe(false);
+ });
+
+ it('recognizes specific technology queries for import icon enrichment', () => {
+ expect(isSpecificTechnologyIconQuery('postgres')).toBe(true);
+ expect(isSpecificTechnologyIconQuery('nginx')).toBe(true);
+ expect(isSpecificTechnologyIconQuery('amazon s3')).toBe(true);
+ expect(isSpecificTechnologyIconQuery('aws lambda')).toBe(true);
+ expect(isSpecificTechnologyIconQuery('service')).toBe(false);
+ expect(isSpecificTechnologyIconQuery('check')).toBe(false);
+ expect(isSpecificTechnologyIconQuery('oauth')).toBe(false);
+ expect(isSpecificTechnologyIconQuery('jwt')).toBe(false);
+ expect(isSpecificTechnologyIconQuery('sso')).toBe(false);
+ expect(isSpecificTechnologyIconQuery('llm')).toBe(false);
+ });
+});
diff --git a/src/lib/semanticClassifier.ts b/src/lib/semanticClassifier.ts
new file mode 100644
index 00000000..ac3d198a
--- /dev/null
+++ b/src/lib/semanticClassifier.ts
@@ -0,0 +1,584 @@
+import type { NodeColorKey } from '@/theme';
+
+export type SemanticCategory =
+ | 'start'
+ | 'end'
+ | 'decision'
+ | 'database'
+ | 'cache'
+ | 'queue'
+ | 'service'
+ | 'frontend'
+ | 'user'
+ | 'action'
+ | 'gateway'
+ | 'auth'
+ | 'storage'
+ | 'process';
+
+export interface SemanticHint {
+ category: SemanticCategory;
+ color: NodeColorKey;
+ iconQuery: string;
+ lucideFallback: string;
+}
+
+const SPECIFIC_TECHNOLOGY_PATTERNS = [
+ /\bpostgres(?:ql)?\b/i,
+ /\bmysql\b/i,
+ /\bmongo(?:db)?\b/i,
+ /\bdynamodb\b/i,
+ /\baurora\b/i,
+ /\bsqlite\b/i,
+ /\bmariadb\b/i,
+ /\bcockroach\b/i,
+ /\bsupabase\b/i,
+ /\bredis\b/i,
+ /\bmemcache(?:d)?\b/i,
+ /\belasticache\b/i,
+ /\bkafka\b/i,
+ /\brabbitmq\b/i,
+ /\bsqs\b/i,
+ /\bpulsar\b/i,
+ /\bnats\b/i,
+ /\bnginx\b/i,
+ /\bhaproxy\b/i,
+ /\balb\b/i,
+ /\bcloudfront\b/i,
+ /\bingress\b/i,
+ /\benvoy\b/i,
+ /\bcognito\b/i,
+ /\breact\b/i,
+ /\bvue\b/i,
+ /\bangular\b/i,
+ /\bsvelte\b/i,
+ /\bnext\.?js\b/i,
+ /\bnuxt\b/i,
+ /\bexpress\b/i,
+ /\bnode\.?js\b/i,
+ /\bdjango\b/i,
+ /\bflask\b/i,
+ /\bfastapi\b/i,
+ /\bspring\b/i,
+ /\brails\b/i,
+ /\blaravel\b/i,
+ /\bgin\b/i,
+ /\bactix\b/i,
+ /\bnest\.?js\b/i,
+ /\bdocker\b/i,
+ /\bkubernetes\b/i,
+ /\bk8s\b/i,
+ /\becs\b/i,
+ /\beks\b/i,
+ /\bcloud\s*run\b/i,
+ /\bs3\b/i,
+ /\bgemini\b/i,
+ /\bopenai\b/i,
+ /\banthropic\b/i,
+ /\bchatgpt\b/i,
+ /\bgpt-?[a-z0-9.]*\b/i,
+ /\bclaude(?:-[a-z0-9.]+)?\b/i,
+ /\bvertexai\b/i,
+ /\bbedrock\b/i,
+ /\bamazon\s*s3\b/i,
+ /\baws\s*lambda\b/i,
+ /\blambda\b/i,
+ // CNCF / cloud-native
+ /\bistio\b/i,
+ /\bcilium\b/i,
+ /\blinkerd\b/i,
+ /\bhelm\b/i,
+ /\bargo(?:\s*cd)?\b/i,
+ /\bdapr\b/i,
+ /\bprometheus\b/i,
+ /\bjaeger\b/i,
+ /\bopentelemetry\b/i,
+ /\botel\b/i,
+ /\bflux(?:cd)?\b/i,
+ /\bharbor\b/i,
+ /\betcd\b/i,
+ /\bcert-?manager\b/i,
+ /\bkeda\b/i,
+ /\bcrossplane\b/i,
+ /\bknative\b/i,
+ /\bvault\b/i,
+ /\bkeycloak\b/i,
+ /\bgrpc\b/i,
+ /\bcontainerd\b/i,
+ /\bfalco\b/i,
+ /\bgrafana\b/i,
+ /\bdatadog\b/i,
+ /\bnewrelic\b/i,
+ /\bsentry\b/i,
+ /\bsplunk\b/i,
+ /\bdynatrace\b/i,
+ // Azure
+ /\bcosmos\s*db\b/i,
+ /\bazure\s*cosmos\b/i,
+ /\bservice\s*bus\b/i,
+ /\bazure\s*service\s*bus\b/i,
+ /\bevent\s*hub(?:s)?\b/i,
+ /\bazure\s*event\s*hub\b/i,
+ /\baks\b/i,
+ /\bazure\s*kubernetes\b/i,
+ /\bazure\s*functions?\b/i,
+ /\bazure\s*openai\b/i,
+ /\bazure\s*monitor\b/i,
+ /\bkey\s*vault\b/i,
+ /\bazure\s*key\s*vault\b/i,
+ /\bcontainer\s*apps?\b/i,
+ /\bazure\s*container\b/i,
+ /\bapi\s*management\b/i,
+ /\bapim\b/i,
+ /\bevent\s*grid\b/i,
+ /\bcognitive\s*services?\b/i,
+ /\bazure\s*devops\b/i,
+ /\bazure\s*sql\b/i,
+ /\bazure\s*blob\b/i,
+ /\bfront\s*door\b/i,
+];
+
+const COMMON_ENGLISH_ICON_TERMS = new Set([
+ 'action',
+ 'admin',
+ 'api',
+ 'app',
+ 'auth',
+ 'backend',
+ 'browser',
+ 'cache',
+ 'check',
+ 'client',
+ 'component',
+ 'compute',
+ 'condition',
+ 'data',
+ 'database',
+ 'db',
+ 'decision',
+ 'edge',
+ 'end',
+ 'external',
+ 'flow',
+ 'frontend',
+ 'gateway',
+ 'input',
+ 'job',
+ 'mobile',
+ 'node',
+ 'output',
+ 'payment',
+ 'process',
+ 'queue',
+ 'screen',
+ 'server',
+ 'service',
+ 'stage',
+ 'start',
+ 'state',
+ 'step',
+ 'storage',
+ 'system',
+ 'task',
+ 'ui',
+ 'user',
+ 'users',
+ 'validator',
+ 'view',
+ 'worker',
+]);
+
+interface ClassifierRule {
+ patterns: RegExp[];
+ category: SemanticCategory;
+ color: NodeColorKey;
+ lucideFallback: string;
+ extractQuery?: (text: string, id: string) => string;
+}
+
+function extractFirstMatch(text: string, pattern: RegExp): string {
+ const match = text.match(pattern);
+ return match?.[1] ?? '';
+}
+
+function createExtractQuery(pattern: RegExp): (text: string) => string {
+ return (text: string) => extractFirstMatch(text, pattern);
+}
+
+const RULES: ClassifierRule[] = [
+ {
+ patterns: [/\bstart\b/i, /\bbegin\b/i, /\binit\b/i, /\bentry\b/i, /\blaunch\b/i],
+ category: 'start',
+ color: 'emerald',
+ lucideFallback: 'play',
+ },
+ {
+ patterns: [/\bend\b/i, /\bfinish\b/i, /\bdone\b/i, /\bcomplete\b/i, /\bstop\b/i, /\bexit\b/i],
+ category: 'end',
+ color: 'red',
+ lucideFallback: 'check-circle',
+ },
+ {
+ patterns: [
+ /\bdb\b/i,
+ /\bdatabase\b/i,
+ /\bsql\b/i,
+ /\bpostgres/i,
+ /\bmysql\b/i,
+ /\bmongo/i,
+ /\bdynamodb\b/i,
+ /\baurora\b/i,
+ /\bsqlite\b/i,
+ /\bmariadb\b/i,
+ /\bcockroach\b/i,
+ /\bsupabase\b/i,
+ ],
+ category: 'database',
+ color: 'violet',
+ lucideFallback: 'database',
+ extractQuery: createExtractQuery(
+ /(postgres(?:ql)?|mysql|mongo(?:db)?|dynamodb|aurora|sqlite|mariadb|cockroach|supabase)/i
+ ),
+ },
+ {
+ patterns: [/\bredis\b/i, /\bmemcache/i, /\bcache\b/i, /\belasticache\b/i],
+ category: 'cache',
+ color: 'red',
+ lucideFallback: 'hard-drive',
+ extractQuery: createExtractQuery(/(redis|memcache(?:d)?|elasticache)/i),
+ },
+ {
+ patterns: [
+ /\bkafka\b/i,
+ /\brabbitmq\b/i,
+ /\bsqs\b/i,
+ /\bpulsar\b/i,
+ /\bnats\b/i,
+ /\bqueue\b/i,
+ /\bbus\b/i,
+ ],
+ category: 'queue',
+ color: 'amber',
+ lucideFallback: 'layers',
+ extractQuery: createExtractQuery(/(kafka|rabbitmq|sqs|pulsar|nats)/i),
+ },
+ {
+ patterns: [
+ /\buser\b/i,
+ /\bactor\b/i,
+ /\bcustomer\b/i,
+ /\badmin\b/i,
+ /\bclient\b/i,
+ /\bperson\b/i,
+ /\bviewer\b/i,
+ ],
+ category: 'user',
+ color: 'blue',
+ lucideFallback: 'user',
+ },
+ {
+ patterns: [
+ /\bapi[- ]?gateway\b/i,
+ /\bgateway\b/i,
+ /\bload[- ]?balancer\b/i,
+ /\bnginx\b/i,
+ /\bhaproxy\b/i,
+ /\balb\b/i,
+ /\bcloudfront\b/i,
+ /\bingress\b/i,
+ /\benvoy\b/i,
+ ],
+ category: 'gateway',
+ color: 'slate',
+ lucideFallback: 'shield',
+ extractQuery: createExtractQuery(/(nginx|haproxy|alb|cloudfront|ingress|envoy)/i),
+ },
+ {
+ patterns: [
+ /\bauth\b/i,
+ /\blogin\b/i,
+ /\bsign[- ]?in\b/i,
+ /\boauth\b/i,
+ /\bjwt\b/i,
+ /\bsso\b/i,
+ /\bcognito\b/i,
+ /\bidentity\b/i,
+ ],
+ category: 'auth',
+ color: 'amber',
+ lucideFallback: 'key-round',
+ extractQuery: createExtractQuery(/(cognito|auth0|keycloak|oauth2)/i),
+ },
+ {
+ patterns: [/\bs3\b/i, /\bblob\b/i, /\bstorage\b/i, /\buploads?\b/i, /\bcdn\b/i],
+ category: 'storage',
+ color: 'yellow',
+ lucideFallback: 'folder',
+ extractQuery: createExtractQuery(/(amazon\s*s3|s3)/i),
+ },
+ {
+ patterns: [
+ /\breact\b/i,
+ /\bvue\b/i,
+ /\bangular\b/i,
+ /\bsvelte\b/i,
+ /\bnext\.?js\b/i,
+ /\bnuxt\b/i,
+ /\bfrontend\b/i,
+ /\bui\b/i,
+ /\bweb\s*app\b/i,
+ /\bclient[- ]?app\b/i,
+ /\bhtml\b/i,
+ /\bcss\b/i,
+ ],
+ category: 'frontend',
+ color: 'blue',
+ lucideFallback: 'monitor',
+ extractQuery: createExtractQuery(/(react|vue|angular|svelte|next(?:\.?js)?|nuxt)/i),
+ },
+ {
+ patterns: [
+ /\bgemini\b/i,
+ /\bgpt\b/i,
+ /\bclaude\b/i,
+ /\bopenai\b/i,
+ /\bllm\b/i,
+ /\bvertexai\b/i,
+ /\bbedrock\b/i,
+ /\bmodel\b/i,
+ ],
+ category: 'service',
+ color: 'blue',
+ lucideFallback: 'cpu',
+ extractQuery: createExtractQuery(
+ /(gemini|openai|chatgpt|gpt-?[a-z0-9.]*|claude(?:-[a-z0-9.]+)?|vertexai|bedrock|anthropic)/i
+ ),
+ },
+ {
+ patterns: [
+ /\bexpress\b/i,
+ /\bnode\.?js\b/i,
+ /\bdjango\b/i,
+ /\bflask\b/i,
+ /\bfastapi\b/i,
+ /\bspring\b/i,
+ /\brails\b/i,
+ /\blaravel\b/i,
+ /\bgin\b/i,
+ /\bactix\b/i,
+ /\bnest\.?js\b/i,
+ /\baws\s*lambda\b/i,
+ /\blambda\b/i,
+ /\bapi\b/i,
+ /\bservice\b/i,
+ /\bbackend\b/i,
+ /\bserver\b/i,
+ /\bmicroservice\b/i,
+ ],
+ category: 'service',
+ color: 'blue',
+ lucideFallback: 'server',
+ extractQuery: createExtractQuery(
+ /(express|node\.?js|django|flask|fastapi|spring|rails|laravel|gin|actix|nest\.?js|aws\s*lambda|lambda)/i
+ ),
+ },
+ {
+ patterns: [
+ /\bdocker\b/i,
+ /\bkubernetes\b/i,
+ /\bk8s\b/i,
+ /\becs\b/i,
+ /\beks\b/i,
+ /\baks\b/i,
+ /\bcloud\s*run\b/i,
+ /\bcontainer\b/i,
+ /\bcontainerd\b/i,
+ ],
+ category: 'service',
+ color: 'blue',
+ lucideFallback: 'container',
+ extractQuery: createExtractQuery(/(docker|kubernetes|k8s|ecs|eks|aks|cloud\s*run|containerd)/i),
+ },
+ {
+ patterns: [
+ /\bistio\b/i,
+ /\bcilium\b/i,
+ /\blinkerd\b/i,
+ /\bkuma\b/i,
+ ],
+ category: 'gateway',
+ color: 'slate',
+ lucideFallback: 'shield',
+ extractQuery: createExtractQuery(/(istio|cilium|linkerd|kuma)/i),
+ },
+ {
+ patterns: [
+ /\bhelm\b/i,
+ /\bargo(?:\s*cd)?\b/i,
+ /\bflux(?:cd)?\b/i,
+ /\bcrossplane\b/i,
+ /\bkeda\b/i,
+ /\bknative\b/i,
+ /\bdapr\b/i,
+ /\bcert-?manager\b/i,
+ /\betcd\b/i,
+ /\bharbor\b/i,
+ /\bfalco\b/i,
+ ],
+ category: 'service',
+ color: 'blue',
+ lucideFallback: 'settings',
+ extractQuery: createExtractQuery(/(helm|argocd|argo|fluxcd|flux|crossplane|keda|knative|dapr|etcd|harbor|falco)/i),
+ },
+ {
+ patterns: [
+ /\bprometheus\b/i,
+ /\bjaeger\b/i,
+ /\bopentelemetry\b/i,
+ /\botel\b/i,
+ /\bgrafana\b/i,
+ /\bdatadog\b/i,
+ /\bnewrelic\b/i,
+ /\bsentry\b/i,
+ /\bsplunk\b/i,
+ /\bdynatrace\b/i,
+ ],
+ category: 'service',
+ color: 'slate',
+ lucideFallback: 'activity',
+ extractQuery: createExtractQuery(/(prometheus|jaeger|opentelemetry|grafana|datadog|newrelic|sentry|splunk|dynatrace)/i),
+ },
+ {
+ patterns: [
+ /\bcosmos\s*db\b/i,
+ /\bazure\s*cosmos\b/i,
+ /\bazure\s*sql\b/i,
+ /\bazure\s*postgres\b/i,
+ /\bazure\s*mysql\b/i,
+ ],
+ category: 'database',
+ color: 'violet',
+ lucideFallback: 'database',
+ extractQuery: createExtractQuery(/(azure-cosmos-db|cosmos|azure-database-postgresql|azure-database-mysql|azure-sql)/i),
+ },
+ {
+ patterns: [
+ /\bservice\s*bus\b/i,
+ /\bevent\s*hub(?:s)?\b/i,
+ /\bevent\s*grid\b/i,
+ ],
+ category: 'queue',
+ color: 'amber',
+ lucideFallback: 'layers',
+ extractQuery: createExtractQuery(/(azure-service-bus|event-hubs|event-grid)/i),
+ },
+ {
+ patterns: [
+ /\bazure\s*functions?\b/i,
+ /\bazure\s*container\s*apps?\b/i,
+ /\bcontainer\s*apps?\b/i,
+ /\bazure\s*openai\b/i,
+ /\bazure\s*monitor\b/i,
+ /\bazure\s*devops\b/i,
+ /\bapi\s*management\b/i,
+ /\bapim\b/i,
+ /\bcognitive\s*services?\b/i,
+ /\bfront\s*door\b/i,
+ ],
+ category: 'service',
+ color: 'blue',
+ lucideFallback: 'server',
+ extractQuery: createExtractQuery(/(azure-functions|container-apps|azure-openai|azure-monitor|azure-devops|api-management|cognitive-services|front-door)/i),
+ },
+ {
+ patterns: [
+ /\bkey\s*vault\b/i,
+ /\bazure\s*key\s*vault\b/i,
+ /\bvault\b/i,
+ ],
+ category: 'auth',
+ color: 'amber',
+ lucideFallback: 'key-round',
+ extractQuery: createExtractQuery(/(key-vault|vault)/i),
+ },
+ {
+ patterns: [
+ /\bazure\s*blob\b/i,
+ /\bazure\s*storage\b/i,
+ ],
+ category: 'storage',
+ color: 'yellow',
+ lucideFallback: 'folder',
+ extractQuery: createExtractQuery(/(azure-blob|blob-block|azure-storage)/i),
+ },
+];
+
+const DEFAULT_HINT: SemanticHint = {
+ category: 'process',
+ color: 'slate',
+ iconQuery: '',
+ lucideFallback: 'box',
+};
+
+export function classifyNode(node: { id: string; label: string; shape?: string }): SemanticHint {
+ if (node.shape === 'diamond') {
+ return { category: 'decision', color: 'amber', iconQuery: '', lucideFallback: 'help-circle' };
+ }
+
+ if (node.shape === 'cylinder') {
+ const text = `${node.id} ${node.label}`;
+ const m = text.match(/(postgres(?:ql)?|mysql|mongo(?:db)?|redis|dynamodb|aurora)/i);
+ return {
+ category: 'database',
+ color: 'violet',
+ iconQuery: m ? m[1] : '',
+ lucideFallback: 'database',
+ };
+ }
+
+ const text = `${node.id} ${node.label}`;
+
+ for (const rule of RULES) {
+ if (rule.patterns.some((p) => p.test(text))) {
+ const iconQuery = rule.extractQuery ? rule.extractQuery(text, node.id) : '';
+ return {
+ category: rule.category,
+ color: rule.color,
+ iconQuery,
+ lucideFallback: rule.lucideFallback,
+ };
+ }
+ }
+
+ return DEFAULT_HINT;
+}
+
+function normalizeIconQueryForGuard(value: string): string {
+ return value.trim().toLowerCase().replace(/\s+/g, ' ');
+}
+
+export function isCommonEnglishIconTerm(value: string): boolean {
+ const normalized = normalizeIconQueryForGuard(value);
+ if (!normalized) {
+ return true;
+ }
+ return COMMON_ENGLISH_ICON_TERMS.has(normalized);
+}
+
+export function isSpecificTechnologyIconQuery(value: string): boolean {
+ const normalized = normalizeIconQueryForGuard(value);
+ if (!normalized || isCommonEnglishIconTerm(normalized)) {
+ return false;
+ }
+ return SPECIFIC_TECHNOLOGY_PATTERNS.some((pattern) => pattern.test(normalized));
+}
+
+export function classifyNodes(
+ nodes: Array<{ id: string; label: string; shape?: string }>
+): Map {
+ const results = new Map();
+ for (const node of nodes) {
+ results.set(node.id, classifyNode(node));
+ }
+ return results;
+}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index ff67ae7e..36c262c6 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -17,7 +17,6 @@ export const DIAGRAM_TYPES = [
'stateDiagram',
'classDiagram',
'erDiagram',
- 'gitGraph',
'mindmap',
'journey',
'architecture',
@@ -114,6 +113,7 @@ export interface EntityNodeData {
}
export interface JourneyNodeData {
+ journeyTitle?: string;
journeySection?: string;
journeyActor?: string;
journeyTask?: string;
@@ -123,17 +123,29 @@ export interface JourneyNodeData {
export interface MindmapNodeData {
mindmapDepth?: number;
mindmapParentId?: string;
+ mindmapAlias?: string;
+ mindmapWrapper?:
+ | 'double-circle'
+ | 'double-square'
+ | 'stadium'
+ | 'subroutine'
+ | 'square'
+ | 'rounded'
+ | 'hexagon';
mindmapSide?: 'left' | 'right';
mindmapBranchStyle?: 'curved' | 'straight';
mindmapCollapsed?: boolean;
}
export interface ArchitectureNodeData {
+ archTitle?: string;
archProvider?: string;
archProviderLabel?: string;
archResourceType?: string;
archEnvironment?: string;
archBoundaryId?: string;
+ archLayerRank?: number;
+ archLayerLabel?: string;
archZone?: string;
archTrustDomain?: string;
archIconPackId?: string;
@@ -150,9 +162,18 @@ export interface SequenceNodeData {
seqMessageFrom?: string;
seqMessageTo?: string;
seqMessageOrder?: number;
- seqActivations?: number[];
+ seqActivations?: Array<{
+ order: number;
+ activate: boolean;
+ }>;
seqNoteTarget?: string;
seqNotePosition?: 'over' | 'left' | 'right';
+ seqFragment?: {
+ type: 'alt' | 'loop' | 'opt' | 'par' | 'break' | 'critical';
+ condition: string;
+ branchKind?: 'start' | 'else' | 'and' | 'option';
+ edgeIds: string[];
+ } | null;
seqFragmentId?: string;
}
@@ -165,18 +186,19 @@ export interface SectionNodeData {
sectionCollapsed?: boolean;
}
-export interface NodeData extends
- NodeLabelData,
- NodeIconData,
- NodeVisualStyleData,
- NodeCanvasMetadata,
- ClassNodeData,
- EntityNodeData,
- JourneyNodeData,
- MindmapNodeData,
- ArchitectureNodeData,
- SequenceNodeData,
- SectionNodeData {
+export interface NodeData
+ extends
+ NodeLabelData,
+ NodeIconData,
+ NodeVisualStyleData,
+ NodeCanvasMetadata,
+ ClassNodeData,
+ EntityNodeData,
+ JourneyNodeData,
+ MindmapNodeData,
+ ArchitectureNodeData,
+ SequenceNodeData,
+ SectionNodeData {
[key: string]: unknown;
}
@@ -236,6 +258,7 @@ export interface EdgeData {
seqFragment?: {
type: 'alt' | 'loop' | 'opt' | 'par' | 'break' | 'critical';
condition: string;
+ branchKind?: 'start' | 'else' | 'and' | 'option';
edgeIds: string[];
} | null;
waypoint?: {
diff --git a/src/services/architectureRoundTrip.test.ts b/src/services/architectureRoundTrip.test.ts
index aa6d3168..4489ade3 100644
--- a/src/services/architectureRoundTrip.test.ts
+++ b/src/services/architectureRoundTrip.test.ts
@@ -18,9 +18,11 @@ describe('architecture round-trip', () => {
expect(first.diagramType).toBe('architecture');
expect(first.nodes.length).toBeGreaterThan(0);
expect(first.edges.length).toBe(1);
+ expect(first.nodes.some((node) => node.data.archTitle === 'Platform')).toBe(true);
const exported = toMermaid(first.nodes, first.edges);
expect(exported.startsWith('architecture-beta')).toBe(true);
+ expect(exported).toContain('title "Platform"');
expect(exported).toContain('api.gateway:R --> L:db.main : HTTPS:443');
const second = parseMermaidByType(exported);
@@ -28,6 +30,7 @@ describe('architecture round-trip', () => {
expect(second.diagramType).toBe('architecture');
expect(second.nodes).toHaveLength(first.nodes.length);
expect(second.edges).toHaveLength(first.edges.length);
+ expect(second.nodes.some((node) => node.data.archTitle === 'Platform')).toBe(true);
expect(second.edges[0].data?.archDirection).toBe('-->');
expect(second.edges[0].data?.archSourceSide).toBe('R');
expect(second.edges[0].data?.archTargetSide).toBe('L');
@@ -49,4 +52,62 @@ describe('architecture round-trip', () => {
const exported = toMermaid(parsed.nodes, parsed.edges);
expect(exported).toContain('web:T --> R:api : HTTP:8080');
});
+
+ it('preserves richer architecture node kinds through import/export/import', () => {
+ const source = `
+ architecture-beta
+ person user[User]
+ container app(server)[App]
+ database_container data(database)[Data Store]
+ user:R --> L:app : https
+ app:R --> L:data : tcp:5432
+ `;
+
+ const first = parseMermaidByType(source);
+ expect(first.error).toBeUndefined();
+ expect(first.diagramType).toBe('architecture');
+ expect(first.nodes.find((node) => node.id === 'user')?.data.archResourceType).toBe('person');
+ expect(first.nodes.find((node) => node.id === 'app')?.data.archResourceType).toBe('container');
+ expect(first.nodes.find((node) => node.id === 'data')?.data.archResourceType).toBe('database_container');
+
+ const exported = toMermaid(first.nodes, first.edges);
+ expect(exported).toContain('person user[User]');
+ expect(exported).toContain('container app(server)[App]');
+ expect(exported).toContain('database_container data(database)[Data Store]');
+
+ const second = parseMermaidByType(exported);
+ expect(second.error).toBeUndefined();
+ expect(second.diagramType).toBe('architecture');
+ expect(second.nodes.find((node) => node.id === 'user')?.data.archResourceType).toBe('person');
+ expect(second.nodes.find((node) => node.id === 'app')?.data.archResourceType).toBe('container');
+ expect(second.nodes.find((node) => node.id === 'data')?.data.archResourceType).toBe('database_container');
+ });
+
+ it('preserves nested architecture groups through import/export/import', () => {
+ const source = `
+ architecture-beta
+ group global[Global]
+ group prod(cloud)[Prod] in global
+ service api(server)[API] in prod
+ `;
+
+ const first = parseMermaidByType(source);
+ expect(first.error).toBeUndefined();
+ expect(first.diagramType).toBe('architecture');
+ expect(first.nodes.find((node) => node.id === 'prod')?.parentId).toBe('global');
+ expect(first.nodes.find((node) => node.id === 'prod')?.data.archBoundaryId).toBe('global');
+ expect(first.nodes.find((node) => node.id === 'api')?.parentId).toBe('prod');
+
+ const exported = toMermaid(first.nodes, first.edges);
+ expect(exported).toContain('group global[Global]');
+ expect(exported).toContain('group prod(cloud)[Prod] in global');
+ expect(exported).toContain('service api(server)[API] in prod');
+
+ const second = parseMermaidByType(exported);
+ expect(second.error).toBeUndefined();
+ expect(second.diagramType).toBe('architecture');
+ expect(second.nodes.find((node) => node.id === 'prod')?.parentId).toBe('global');
+ expect(second.nodes.find((node) => node.id === 'prod')?.data.archBoundaryId).toBe('global');
+ expect(second.nodes.find((node) => node.id === 'api')?.parentId).toBe('prod');
+ });
});
diff --git a/src/services/composeDiagramForDisplay.test.ts b/src/services/composeDiagramForDisplay.test.ts
index 8fce5a57..1fd9b40e 100644
--- a/src/services/composeDiagramForDisplay.test.ts
+++ b/src/services/composeDiagramForDisplay.test.ts
@@ -31,13 +31,20 @@ describe('composeDiagramForDisplay', () => {
const nodes = [createNode('a'), createNode('b')];
const edges = [createEdge('e1', 'a', 'b')];
- await composeDiagramForDisplay(nodes, edges, { direction: 'LR', algorithm: 'layered', spacing: 'compact' });
+ await composeDiagramForDisplay(nodes, edges, {
+ direction: 'LR',
+ algorithm: 'layered',
+ spacing: 'compact',
+ contentDensity: 'compact',
+ });
expect(getElkLayout).toHaveBeenCalledWith(nodes, edges, {
direction: 'LR',
algorithm: 'layered',
spacing: 'compact',
+ contentDensity: 'compact',
diagramType: undefined,
+ source: undefined,
});
});
diff --git a/src/services/composeDiagramForDisplay.ts b/src/services/composeDiagramForDisplay.ts
index 9c2913d3..a38b78aa 100644
--- a/src/services/composeDiagramForDisplay.ts
+++ b/src/services/composeDiagramForDisplay.ts
@@ -1,60 +1,78 @@
import type { DiagramType, FlowEdge, FlowNode } from '@/lib/types';
+import { autoFitSectionsToChildren } from '@/hooks/node-operations/sectionOperations';
import type { LayoutAlgorithm, LayoutOptions } from '@/services/elkLayout';
import { relayoutMindmapComponent, syncMindmapEdges } from '@/lib/mindmapLayout';
+import { relayoutSequenceDiagram } from '@/services/sequenceLayout';
-interface ComposeDiagramForDisplayOptions extends Pick {
- direction?: LayoutOptions['direction'];
- algorithm?: LayoutAlgorithm;
- diagramType?: DiagramType | string;
+interface ComposeDiagramForDisplayOptions
+ extends Pick {
+ direction?: LayoutOptions['direction'];
+ algorithm?: LayoutAlgorithm;
+ diagramType?: DiagramType | string;
}
function isMindmapDisplayTarget(nodes: FlowNode[], diagramType?: string): boolean {
- if (diagramType === 'mindmap') {
- return nodes.some((node) => node.type === 'mindmap');
- }
+ if (diagramType === 'mindmap') {
+ return nodes.some((node) => node.type === 'mindmap');
+ }
- const visibleNodes = nodes.filter((node) => !node.hidden);
- return visibleNodes.length > 0 && visibleNodes.every((node) => node.type === 'mindmap');
+ const visibleNodes = nodes.filter((node) => !node.hidden);
+ return visibleNodes.length > 0 && visibleNodes.every((node) => node.type === 'mindmap');
}
-function relayoutAllMindmapComponents(nodes: FlowNode[], edges: FlowEdge[]): { nodes: FlowNode[]; edges: FlowEdge[] } {
- const mindmapRootIds = nodes
- .filter((node) => node.type === 'mindmap' && typeof node.data.mindmapParentId !== 'string')
- .map((node) => node.id);
+function relayoutAllMindmapComponents(
+ nodes: FlowNode[],
+ edges: FlowEdge[]
+): { nodes: FlowNode[]; edges: FlowEdge[] } {
+ const mindmapRootIds = nodes
+ .filter((node) => node.type === 'mindmap' && typeof node.data.mindmapParentId !== 'string')
+ .map((node) => node.id);
- const fallbackRootIds = mindmapRootIds.length > 0
- ? mindmapRootIds
- : nodes.filter((node) => node.type === 'mindmap').map((node) => node.id);
+ const fallbackRootIds =
+ mindmapRootIds.length > 0
+ ? mindmapRootIds
+ : nodes.filter((node) => node.type === 'mindmap').map((node) => node.id);
- const layoutedNodes = fallbackRootIds.reduce(
- (currentNodes, rootId) => relayoutMindmapComponent(currentNodes, edges, rootId),
- nodes
- );
+ const layoutedNodes = fallbackRootIds.reduce(
+ (currentNodes, rootId) => relayoutMindmapComponent(currentNodes, edges, rootId),
+ nodes
+ );
- return {
- nodes: layoutedNodes,
- edges: syncMindmapEdges(layoutedNodes, edges),
- };
+ return {
+ nodes: layoutedNodes,
+ edges: syncMindmapEdges(layoutedNodes, edges),
+ };
}
export async function composeDiagramForDisplay(
- nodes: FlowNode[],
- edges: FlowEdge[],
- options: ComposeDiagramForDisplayOptions = {}
+ nodes: FlowNode[],
+ edges: FlowEdge[],
+ options: ComposeDiagramForDisplayOptions = {}
): Promise<{ nodes: FlowNode[]; edges: FlowEdge[] }> {
- if (nodes.length === 0) {
- return { nodes, edges };
- }
-
- if (isMindmapDisplayTarget(nodes, options.diagramType)) {
- return relayoutAllMindmapComponents(nodes, edges);
- }
-
- const { getElkLayout } = await import('@/services/elkLayout');
- return getElkLayout(nodes, edges, {
- direction: options.direction ?? 'TB',
- algorithm: options.algorithm ?? 'layered',
- spacing: options.spacing ?? 'normal',
- diagramType: options.diagramType,
- });
+ if (nodes.length === 0) {
+ return { nodes, edges };
+ }
+
+ if (isMindmapDisplayTarget(nodes, options.diagramType)) {
+ return relayoutAllMindmapComponents(nodes, edges);
+ }
+
+ if (options.diagramType === 'sequence') {
+ return relayoutSequenceDiagram(nodes, edges);
+ }
+
+ const { getElkLayout } = await import('@/services/elkLayout');
+ const layouted = await getElkLayout(nodes, edges, {
+ direction: options.direction ?? 'TB',
+ algorithm: options.algorithm,
+ spacing: options.spacing ?? 'normal',
+ contentDensity: options.contentDensity,
+ diagramType: options.diagramType,
+ source: options.source,
+ });
+
+ return {
+ nodes: autoFitSectionsToChildren(layouted.nodes),
+ edges: layouted.edges,
+ };
}
diff --git a/src/services/domainLibrary.test.ts b/src/services/domainLibrary.test.ts
index 3027b78c..64698c9f 100644
--- a/src/services/domainLibrary.test.ts
+++ b/src/services/domainLibrary.test.ts
@@ -68,6 +68,8 @@ describe('domainLibrary', () => {
expect(node.type).toBe('custom');
expect(node.data.assetPresentation).toBe('icon');
- expect(node.data.customIconUrl).toBe('/mock/athena.svg');
+ expect(node.data.assetProvider).toBe('aws');
+ expect(node.data.assetCategory).toBe('Analytics');
+ expect(node.data.customIconUrl).toBeUndefined();
});
});
diff --git a/src/services/domainLibrary.ts b/src/services/domainLibrary.ts
index 093ebb91..7ddd655e 100644
--- a/src/services/domainLibrary.ts
+++ b/src/services/domainLibrary.ts
@@ -1,5 +1,6 @@
import type { Node } from '@/lib/reactflowCompat';
import type { NodeData } from '@/lib/types';
+import { createProviderIconData, normalizeNodeIconData } from '@/lib/nodeIconState';
export type DomainLibraryCategory = 'aws' | 'azure' | 'gcp' | 'cncf' | 'developer' | 'network' | 'security' | 'c4' | 'icons';
@@ -71,24 +72,25 @@ export function createDomainLibraryNode(
layerId: string
): Node {
if (item.assetPresentation === 'icon' && (item.previewUrl || item.icon)) {
+ const data = normalizeNodeIconData({
+ label: item.label,
+ subLabel: '',
+ color: 'custom',
+ customColor: '#ffffff',
+ ...(item.previewUrl ? { customIconUrl: item.previewUrl } : {}),
+ ...(item.icon ? { icon: item.icon } : {}),
+ assetPresentation: 'icon',
+ assetProvider: item.category,
+ assetCategory: item.providerShapeCategory,
+ archIconPackId: item.archIconPackId,
+ archIconShapeId: item.archIconShapeId,
+ layerId,
+ });
return {
id,
type: 'custom',
position,
- data: {
- label: item.label,
- subLabel: '',
- color: 'custom',
- customColor: '#ffffff',
- ...(item.previewUrl ? { customIconUrl: item.previewUrl } : {}),
- ...(item.icon ? { icon: item.icon } : {}),
- assetPresentation: 'icon',
- assetProvider: item.category,
- assetCategory: item.providerShapeCategory,
- archIconPackId: item.archIconPackId,
- archIconShapeId: item.archIconShapeId,
- layerId,
- },
+ data,
style: { width: 96 },
};
}
@@ -107,8 +109,14 @@ export function createDomainLibraryNode(
archProvider: item.category,
archResourceType: item.archResourceType || 'service',
archEnvironment: 'default',
- ...(item.archIconPackId ? { archIconPackId: item.archIconPackId } : {}),
- ...(item.archIconShapeId ? { archIconShapeId: item.archIconShapeId } : {}),
+ ...(item.archIconPackId && item.archIconShapeId
+ ? createProviderIconData({
+ packId: item.archIconPackId,
+ shapeId: item.archIconShapeId,
+ provider: item.category,
+ category: item.providerShapeCategory,
+ })
+ : {}),
layerId,
},
};
diff --git a/src/services/elk-layout/options.test.ts b/src/services/elk-layout/options.test.ts
new file mode 100644
index 00000000..a029775c
--- /dev/null
+++ b/src/services/elk-layout/options.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from 'vitest';
+import { buildResolvedLayoutConfiguration } from './options';
+
+describe('buildResolvedLayoutConfiguration', () => {
+ it('tightens import spacing for compact short-label diagrams', () => {
+ const config = buildResolvedLayoutConfiguration({
+ direction: 'TB',
+ spacing: 'compact',
+ contentDensity: 'compact',
+ diagramType: 'flowchart',
+ source: 'import',
+ });
+
+ expect(config.dims.nodeNode).toBe('26');
+ expect(config.dims.nodeLayer).toBe('42');
+ });
+
+ it('keeps architecture diagrams from collapsing below readable spacing', () => {
+ const config = buildResolvedLayoutConfiguration({
+ direction: 'TB',
+ spacing: 'compact',
+ contentDensity: 'compact',
+ diagramType: 'architecture',
+ source: 'import',
+ });
+
+ expect(config.dims.nodeNode).toBe('56');
+ expect(config.dims.nodeLayer).toBe('88');
+ });
+});
diff --git a/src/services/elk-layout/options.ts b/src/services/elk-layout/options.ts
index 189d2b96..37ed78a0 100644
--- a/src/services/elk-layout/options.ts
+++ b/src/services/elk-layout/options.ts
@@ -1,204 +1,239 @@
-import type { LayoutAlgorithm, LayoutDirection, LayoutOptions, ResolvedLayoutConfiguration } from './types';
+import {
+ SECTION_CONTENT_PADDING_TOP,
+ SECTION_PADDING_BOTTOM,
+ SECTION_PADDING_X,
+} from '@/hooks/node-operations/sectionBounds';
+import type {
+ LayoutAlgorithm,
+ LayoutDirection,
+ LayoutOptions,
+ ResolvedLayoutConfiguration,
+} from './types';
-// Map user-friendly direction codes to ELK direction values
const DIRECTION_MAP: Record = {
- TB: 'DOWN',
- LR: 'RIGHT',
- RL: 'LEFT',
- BT: 'UP',
+ TB: 'DOWN',
+ LR: 'RIGHT',
+ RL: 'LEFT',
+ BT: 'UP',
};
-function getSpacingDimensions(spacing: LayoutOptions['spacing'] = 'normal', isHorizontal: boolean): {
- nodeNode: string;
- nodeLayer: string;
- component: string;
+function getSpacingDimensions(
+ spacing: LayoutOptions['spacing'] = 'normal',
+ isHorizontal: boolean,
+ options: LayoutOptions
+): {
+ nodeNode: string;
+ nodeLayer: string;
+ component: string;
} {
- let nodeNode = 80;
- let nodeLayer = 150;
-
- switch (spacing) {
- case 'compact':
- nodeNode = 40;
- nodeLayer = 80;
- break;
- case 'loose':
- nodeNode = 150;
- nodeLayer = 250;
- break;
- case 'normal':
- default:
- nodeNode = 80;
- nodeLayer = 150;
- }
-
- if (isHorizontal) {
- nodeLayer *= 1.2;
- }
-
- return {
- nodeNode: String(nodeNode),
- nodeLayer: String(nodeLayer),
- component: String(nodeLayer),
- };
+ let nodeNode = 56;
+ let nodeLayer = 84;
+
+ switch (spacing) {
+ case 'compact':
+ nodeNode = 40;
+ nodeLayer = 60;
+ break;
+ case 'loose':
+ nodeNode = 76;
+ nodeLayer = 116;
+ break;
+ case 'normal':
+ default:
+ nodeNode = 56;
+ nodeLayer = 84;
+ }
+
+ if (options.source === 'import') {
+ nodeNode -= 6;
+ nodeLayer -= 8;
+ }
+
+ switch (options.contentDensity) {
+ case 'compact':
+ nodeNode -= 8;
+ nodeLayer -= 10;
+ break;
+ case 'verbose':
+ nodeNode += 10;
+ nodeLayer += 14;
+ break;
+ default:
+ break;
+ }
+
+ switch (options.diagramType) {
+ case 'architecture':
+ case 'infrastructure':
+ nodeNode = Math.max(nodeNode, 56);
+ nodeLayer = Math.max(nodeLayer, 88);
+ break;
+ case 'flowchart':
+ case 'stateDiagram':
+ case 'classDiagram':
+ case 'erDiagram':
+ nodeNode = Math.min(nodeNode, spacing === 'loose' ? 72 : 52);
+ nodeLayer = Math.min(nodeLayer, spacing === 'loose' ? 108 : 76);
+ break;
+ default:
+ break;
+ }
+
+ if (isHorizontal) {
+ nodeLayer = Math.round(nodeLayer * 1.12);
+ }
+
+ return {
+ nodeNode: String(nodeNode),
+ nodeLayer: String(nodeLayer),
+ component: String(nodeLayer),
+ };
}
function isArchitectureLikeDiagram(diagramType: string | undefined): boolean {
- return diagramType === 'architecture' || diagramType === 'infrastructure';
-}
-
-function applyDiagramTypeSpacingHeuristics(
- dims: { nodeNode: string; nodeLayer: string; component: string },
- options: LayoutOptions
-): { nodeNode: string; nodeLayer: string; component: string } {
- if (!isArchitectureLikeDiagram(options.diagramType)) {
- return dims;
- }
-
- const nodeNode = Math.round(Number(dims.nodeNode) * 1.35);
- const nodeLayer = Math.round(Number(dims.nodeLayer) * 1.3);
- const component = Math.round(Number(dims.component) * 1.25);
-
- return {
- nodeNode: String(nodeNode),
- nodeLayer: String(nodeLayer),
- component: String(component),
- };
+ return diagramType === 'architecture' || diagramType === 'infrastructure';
}
function getAlgorithmOptions(
- algorithm: LayoutAlgorithm,
- layerSpacing: number,
- options: LayoutOptions
+ algorithm: LayoutAlgorithm,
+ layerSpacing: number,
+ options: LayoutOptions
): Record {
- const algorithmOptions: Record = {};
-
- switch (algorithm) {
- case 'mrtree':
- algorithmOptions['elk.algorithm'] = 'org.eclipse.elk.mrtree';
- break;
- case 'force':
- algorithmOptions['elk.algorithm'] = 'org.eclipse.elk.force';
- break;
- case 'stress':
- algorithmOptions['elk.algorithm'] = 'org.eclipse.elk.stress';
- break;
- case 'radial':
- algorithmOptions['elk.algorithm'] = 'org.eclipse.elk.radial';
- break;
- default:
- algorithmOptions['elk.algorithm'] = `org.eclipse.elk.${algorithm}`;
- }
- if (algorithm === 'layered') {
- const edgeNodeSpacing = String(Math.round(layerSpacing * 0.33));
- const architectureLike = isArchitectureLikeDiagram(options.diagramType);
- Object.assign(algorithmOptions, {
- 'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP',
- 'elk.layered.crossingMinimization.thoroughness': '64',
- 'elk.layered.nodePlacement.strategy': architectureLike ? 'BRANDES_KOEPF' : 'NETWORK_SIMPLEX',
- 'elk.layered.nodePlacement.favorStraightEdges': 'true',
- 'elk.layered.mergeEdges': 'true',
- 'elk.layered.unnecessaryBendpoints': 'true',
- 'elk.edgeRouting': 'ORTHOGONAL',
- 'elk.portConstraints': 'FIXED_SIDE',
- 'elk.layered.spacing.edgeNodeBetweenLayers': edgeNodeSpacing,
- 'elk.layered.spacing.edgeEdgeBetweenLayers': '30',
- 'elk.spacing.edgeEdge': '12',
- 'elk.separateConnectedComponents': 'true',
- 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES',
- 'elk.layered.compaction.postCompaction.strategy': 'EDGE_LENGTH',
- 'elk.layered.highDegreeNode.treatment': 'true',
- 'elk.layered.highDegreeNode.threshold': '4',
- 'elk.layered.highDegreeNode.treeHeight': '2',
- ...(architectureLike
- ? {
- 'elk.spacing.edgeNode': '24',
- 'elk.spacing.edgeEdge': '18',
- 'elk.layered.spacing.edgeEdgeBetweenLayers': '42',
- 'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED',
- 'elk.layered.priority.direction': '1',
- }
- : {}),
- });
- } else if (algorithm === 'mrtree') {
- Object.assign(algorithmOptions, {
- 'elk.separateConnectedComponents': 'true',
- 'elk.portConstraints': 'FIXED_SIDE', // Lock to centers to force centralized trunk grouping
- 'elk.spacing.edgeEdge': '12',
- });
-
- } else if (algorithm === 'force') {
- Object.assign(algorithmOptions, {
- 'elk.force.iterations': '500',
- 'elk.force.repulsivePower': String(layerSpacing / 20),
- 'elk.portConstraints': 'FREE', // Force layout needs free ports
- });
- } else if (algorithm === 'stress') {
- algorithmOptions['elk.stress.desiredEdgeLength'] = String(layerSpacing);
- algorithmOptions['elk.portConstraints'] = 'FREE';
- } else if (algorithm === 'radial') {
- algorithmOptions['elk.radial.radius'] = String(layerSpacing);
- algorithmOptions['elk.portConstraints'] = 'FREE';
- }
-
- return algorithmOptions;
+ const algorithmOptions: Record = {};
+
+ switch (algorithm) {
+ case 'mrtree':
+ algorithmOptions['elk.algorithm'] = 'org.eclipse.elk.mrtree';
+ break;
+ case 'force':
+ algorithmOptions['elk.algorithm'] = 'org.eclipse.elk.force';
+ break;
+ case 'stress':
+ algorithmOptions['elk.algorithm'] = 'org.eclipse.elk.stress';
+ break;
+ case 'radial':
+ algorithmOptions['elk.algorithm'] = 'org.eclipse.elk.radial';
+ break;
+ default:
+ algorithmOptions['elk.algorithm'] = `org.eclipse.elk.${algorithm}`;
+ }
+ if (algorithm === 'layered') {
+ const edgeNodeSpacing = String(Math.round(layerSpacing * 0.33));
+ const architectureLike = isArchitectureLikeDiagram(options.diagramType);
+ Object.assign(algorithmOptions, {
+ 'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP',
+ 'elk.layered.crossingMinimization.thoroughness': '64',
+ 'elk.layered.nodePlacement.strategy': architectureLike ? 'BRANDES_KOEPF' : 'NETWORK_SIMPLEX',
+ 'elk.layered.nodePlacement.favorStraightEdges': 'true',
+ 'elk.layered.mergeEdges': 'true',
+ 'elk.layered.unnecessaryBendpoints': 'true',
+ 'elk.edgeRouting': 'ORTHOGONAL',
+ 'elk.portConstraints': architectureLike ? 'FIXED_SIDE' : 'FIXED_ORDER',
+ 'elk.layered.spacing.edgeNodeBetweenLayers': edgeNodeSpacing,
+ 'elk.layered.spacing.edgeEdgeBetweenLayers': '30',
+ 'elk.spacing.edgeEdge': '12',
+ 'elk.separateConnectedComponents': 'true',
+ 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES',
+ 'elk.layered.compaction.postCompaction.strategy': 'EDGE_LENGTH',
+ 'elk.layered.highDegreeNode.treatment': 'true',
+ 'elk.layered.highDegreeNode.threshold': '4',
+ 'elk.layered.highDegreeNode.treeHeight': '2',
+ ...(architectureLike
+ ? {
+ 'elk.spacing.edgeNode': '24',
+ 'elk.spacing.edgeEdge': '18',
+ 'elk.layered.spacing.edgeEdgeBetweenLayers': '42',
+ 'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED',
+ 'elk.layered.priority.direction': '1',
+ }
+ : {}),
+ });
+ } else if (algorithm === 'mrtree') {
+ Object.assign(algorithmOptions, {
+ 'elk.separateConnectedComponents': 'true',
+ // FIXED_SIDE constrains ports to a node face, preventing mrtree from
+ // routing edges through the center and producing cleaner trunk grouping.
+ 'elk.portConstraints': 'FIXED_SIDE',
+ 'elk.spacing.edgeEdge': '12',
+ });
+ } else if (algorithm === 'force') {
+ Object.assign(algorithmOptions, {
+ 'elk.force.iterations': '500',
+ 'elk.force.repulsivePower': String(layerSpacing / 20),
+ 'elk.portConstraints': 'FREE', // Force layout needs free ports
+ });
+ } else if (algorithm === 'stress') {
+ algorithmOptions['elk.stress.desiredEdgeLength'] = String(layerSpacing);
+ algorithmOptions['elk.portConstraints'] = 'FREE';
+ } else if (algorithm === 'radial') {
+ algorithmOptions['elk.radial.radius'] = String(layerSpacing);
+ algorithmOptions['elk.portConstraints'] = 'FREE';
+ }
+
+ return algorithmOptions;
}
export function getDeterministicSeedOptions(algorithm: LayoutAlgorithm): Record {
- if (algorithm === 'force' || algorithm === 'stress' || algorithm === 'radial') {
- return { 'elk.randomSeed': '1337' };
- }
- return {};
+ if (algorithm === 'force' || algorithm === 'stress' || algorithm === 'radial') {
+ return { 'elk.randomSeed': '1337' };
+ }
+ return {};
}
-export function resolveLayoutPresetOptions(options: LayoutOptions): Pick {
- if (!options.preset) {
- return {
- algorithm: options.algorithm ?? 'layered',
- direction: options.direction ?? 'TB',
- spacing: options.spacing ?? 'normal',
- };
- }
+export function resolveLayoutPresetOptions(
+ options: LayoutOptions
+): Pick {
+ if (!options.preset) {
+ return {
+ algorithm: options.algorithm ?? 'layered',
+ direction: options.direction ?? 'TB',
+ spacing: options.spacing ?? 'normal',
+ };
+ }
- if (options.preset === 'hierarchical') {
- return { algorithm: 'layered', direction: 'TB', spacing: 'normal' };
- }
+ if (options.preset === 'hierarchical') {
+ return { algorithm: 'layered', direction: 'TB', spacing: 'normal' };
+ }
- if (options.preset === 'orthogonal-compact') {
- return { algorithm: 'layered', direction: 'LR', spacing: 'compact' };
- }
+ if (options.preset === 'orthogonal-compact') {
+ return { algorithm: 'layered', direction: 'LR', spacing: 'compact' };
+ }
- return { algorithm: 'layered', direction: 'LR', spacing: 'loose' };
+ return { algorithm: 'layered', direction: 'LR', spacing: 'loose' };
}
-export function buildResolvedLayoutConfiguration(options: LayoutOptions): ResolvedLayoutConfiguration {
- const {
- direction = 'TB',
- algorithm = 'layered',
- spacing = 'normal',
- } = resolveLayoutPresetOptions(options);
- const elkDirection = DIRECTION_MAP[direction] || 'DOWN';
- const isHorizontal = direction === 'LR' || direction === 'RL';
-
- const dims = applyDiagramTypeSpacingHeuristics(getSpacingDimensions(spacing, isHorizontal), options);
- const algoOptions = getAlgorithmOptions(algorithm, parseFloat(dims.nodeLayer), options);
- const deterministicSeedOptions = getDeterministicSeedOptions(algorithm);
- const layoutOptions = {
- 'elk.direction': elkDirection,
- 'elk.spacing.nodeNode': dims.nodeNode,
- 'elk.layered.spacing.nodeNodeBetweenLayers': dims.nodeLayer,
- 'elk.spacing.componentComponent': dims.component,
- 'elk.padding': '[top=50,left=50,bottom=50,right=50]',
- ...algoOptions,
- ...deterministicSeedOptions,
- };
-
- return {
- algorithm,
- direction,
- spacing,
- elkDirection,
- isHorizontal,
- dims,
- layoutOptions,
- };
+export function buildResolvedLayoutConfiguration(
+ options: LayoutOptions
+): ResolvedLayoutConfiguration {
+ const {
+ direction = 'TB',
+ algorithm = 'layered',
+ spacing = 'normal',
+ } = resolveLayoutPresetOptions(options);
+ const elkDirection = DIRECTION_MAP[direction] || 'DOWN';
+ const isHorizontal = direction === 'LR' || direction === 'RL';
+
+ const dims = getSpacingDimensions(spacing, isHorizontal, options);
+ const algoOptions = getAlgorithmOptions(algorithm, parseFloat(dims.nodeLayer), options);
+ const deterministicSeedOptions = getDeterministicSeedOptions(algorithm);
+ const layoutOptions = {
+ 'elk.direction': elkDirection,
+ 'elk.spacing.nodeNode': dims.nodeNode,
+ 'elk.layered.spacing.nodeNodeBetweenLayers': dims.nodeLayer,
+ 'elk.spacing.componentComponent': dims.component,
+ 'elk.padding': `[top=${SECTION_CONTENT_PADDING_TOP},left=${SECTION_PADDING_X},bottom=${SECTION_PADDING_BOTTOM},right=${SECTION_PADDING_X}]`,
+ 'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
+ ...algoOptions,
+ ...deterministicSeedOptions,
+ };
+
+ return {
+ algorithm,
+ direction,
+ spacing,
+ elkDirection,
+ isHorizontal,
+ dims,
+ layoutOptions,
+ };
}
diff --git a/src/services/elk-layout/textSizing.ts b/src/services/elk-layout/textSizing.ts
new file mode 100644
index 00000000..466cc464
--- /dev/null
+++ b/src/services/elk-layout/textSizing.ts
@@ -0,0 +1,107 @@
+export interface TextBoxEstimateOptions {
+ minWidth?: number;
+ minHeight?: number;
+ maxWidth?: number;
+ charWidth?: number;
+ lineHeight?: number;
+ horizontalPadding?: number;
+ verticalPadding?: number;
+}
+
+export interface TextBoxEstimate {
+ width: number;
+ height: number;
+ lineCount: number;
+}
+
+const DEFAULT_CHAR_WIDTH = 9.5;
+const DEFAULT_LINE_HEIGHT = 22;
+const DEFAULT_HORIZONTAL_PADDING = 12;
+const DEFAULT_VERTICAL_PADDING = 16;
+export const DEFAULT_MAX_WIDTH = 200;
+
+function splitLongToken(token: string, maxCharsPerLine: number): string[] {
+ if (token.length <= maxCharsPerLine) {
+ return [token];
+ }
+
+ const parts: string[] = [];
+ for (let index = 0; index < token.length; index += maxCharsPerLine) {
+ parts.push(token.slice(index, index + maxCharsPerLine));
+ }
+ return parts;
+}
+
+function estimateWrappedLineLengths(
+ label: string,
+ maxCharsPerLine: number
+): number[] {
+ const normalized = label.trim();
+ if (!normalized) {
+ return [0];
+ }
+
+ const paragraphs = normalized.split(/\r?\n/);
+ const lineLengths: number[] = [];
+
+ for (const paragraph of paragraphs) {
+ const words = paragraph.trim().split(/\s+/).filter(Boolean);
+ if (words.length === 0) {
+ lineLengths.push(0);
+ continue;
+ }
+
+ let currentLineLength = 0;
+ for (const word of words) {
+ const parts = splitLongToken(word, maxCharsPerLine);
+ for (const part of parts) {
+ if (currentLineLength === 0) {
+ currentLineLength = part.length;
+ continue;
+ }
+
+ const nextLength = currentLineLength + 1 + part.length;
+ if (nextLength <= maxCharsPerLine) {
+ currentLineLength = nextLength;
+ continue;
+ }
+
+ lineLengths.push(currentLineLength);
+ currentLineLength = part.length;
+ }
+ }
+
+ lineLengths.push(currentLineLength);
+ }
+
+ return lineLengths.length > 0 ? lineLengths : [0];
+}
+
+export function estimateWrappedTextBox(
+ label: string,
+ options: TextBoxEstimateOptions = {}
+): TextBoxEstimate {
+ const charWidth = options.charWidth ?? DEFAULT_CHAR_WIDTH;
+ const lineHeight = options.lineHeight ?? DEFAULT_LINE_HEIGHT;
+ const horizontalPadding = options.horizontalPadding ?? DEFAULT_HORIZONTAL_PADDING;
+ const verticalPadding = options.verticalPadding ?? DEFAULT_VERTICAL_PADDING;
+ const maxWidth = options.maxWidth ?? DEFAULT_MAX_WIDTH;
+ const minWidth = options.minWidth ?? 0;
+ const minHeight = options.minHeight ?? 0;
+ const usableTextWidth = Math.max(maxWidth - horizontalPadding * 2, charWidth);
+ const maxCharsPerLine = Math.max(1, Math.floor(usableTextWidth / charWidth));
+ const lineLengths = estimateWrappedLineLengths(label, maxCharsPerLine);
+ const longestLine = Math.max(...lineLengths, 0);
+ const lineCount = Math.max(1, lineLengths.length);
+ const estimatedWidth = Math.min(
+ maxWidth,
+ Math.ceil(longestLine * charWidth + horizontalPadding * 2)
+ );
+ const estimatedHeight = Math.ceil(lineCount * lineHeight + verticalPadding * 2);
+
+ return {
+ width: Math.max(minWidth, estimatedWidth),
+ height: Math.max(minHeight, estimatedHeight),
+ lineCount,
+ };
+}
diff --git a/src/services/elk-layout/types.ts b/src/services/elk-layout/types.ts
index cd832ee7..a69b0cd7 100644
--- a/src/services/elk-layout/types.ts
+++ b/src/services/elk-layout/types.ts
@@ -7,8 +7,11 @@ export interface LayoutOptions {
direction?: 'TB' | 'LR' | 'RL' | 'BT';
algorithm?: LayoutAlgorithm;
spacing?: 'compact' | 'normal' | 'loose';
+ contentDensity?: 'compact' | 'balanced' | 'verbose';
preset?: 'hierarchical' | 'orthogonal-compact' | 'orthogonal-spacious';
diagramType?: string;
+ /** 'import' triggers compact node size estimates and tighter spacing defaults */
+ source?: 'import' | 'canvas';
}
export type ResolvedLayoutConfiguration = {
diff --git a/src/services/elkLayout.test.ts b/src/services/elkLayout.test.ts
index f57ecfbb..bbc9d063 100644
--- a/src/services/elkLayout.test.ts
+++ b/src/services/elkLayout.test.ts
@@ -1,10 +1,13 @@
import { describe, expect, it } from 'vitest';
import type { FlowEdge, FlowNode } from '@/lib/types';
import {
+ applyElkLayoutToNodes,
buildResolvedLayoutConfiguration,
getDeterministicSeedOptions,
normalizeElkEdgeBoundaryFanout,
normalizeLayoutInputsForDeterminism,
+ normalizeParentedElkPositions,
+ resolveAutomaticLayoutAlgorithm,
resolveLayoutedEdgeHandles,
resolveLayoutPresetOptions,
shouldUseLightweightLayoutPostProcessing,
@@ -24,7 +27,9 @@ function createEdge(id: string, source: string, target: string): FlowEdge {
return { id, source, target } as FlowEdge;
}
-function createPositionMap(entries: Array<[string, { x: number; y: number; width?: number; height?: number }]>) {
+function createPositionMap(
+ entries: Array<[string, { x: number; y: number; width?: number; height?: number }]>
+) {
return new Map(entries);
}
@@ -59,16 +64,8 @@ describe('normalizeLayoutInputsForDeterminism', () => {
});
it('uses deterministic component tie-break ordering for top-level nodes and edges', () => {
- const nodes = [
- createNode('z1'),
- createNode('b1'),
- createNode('a1'),
- createNode('c1'),
- ];
- const edges = [
- createEdge('edge-bc', 'b1', 'c1'),
- createEdge('edge-za', 'z1', 'a1'),
- ];
+ const nodes = [createNode('z1'), createNode('b1'), createNode('a1'), createNode('c1')];
+ const edges = [createEdge('edge-bc', 'b1', 'c1'), createEdge('edge-za', 'z1', 'a1')];
const normalized = normalizeLayoutInputsForDeterminism(nodes, edges);
@@ -93,8 +90,14 @@ describe('normalizeLayoutInputsForDeterminism', () => {
const normalized = normalizeLayoutInputsForDeterminism(nodes, edges);
expect(normalized.topLevelNodes.map((node) => node.id)).toEqual(['group-a', 'group-b']);
- expect((normalized.childrenByParent.get('group-a') || []).map((node) => node.id)).toEqual(['a-child-1', 'a-child-2']);
- expect((normalized.childrenByParent.get('group-b') || []).map((node) => node.id)).toEqual(['b-child-1', 'b-child-2']);
+ expect((normalized.childrenByParent.get('group-a') || []).map((node) => node.id)).toEqual([
+ 'a-child-1',
+ 'a-child-2',
+ ]);
+ expect((normalized.childrenByParent.get('group-b') || []).map((node) => node.id)).toEqual([
+ 'b-child-1',
+ 'b-child-2',
+ ]);
});
});
@@ -160,6 +163,7 @@ describe('buildResolvedLayoutConfiguration', () => {
expect(compact.layoutOptions['elk.layered.nodePlacement.favorStraightEdges']).toBe('true');
expect(compact.layoutOptions['elk.layered.mergeEdges']).toBe('true');
expect(compact.layoutOptions['elk.layered.unnecessaryBendpoints']).toBe('true');
+ expect(Number(compact.dims.nodeNode)).toBe(40);
});
it('applies more spacious layered heuristics for architecture diagrams', () => {
@@ -175,14 +179,91 @@ describe('buildResolvedLayoutConfiguration', () => {
diagramType: 'architecture',
});
- expect(Number(architecture.dims.nodeNode)).toBeGreaterThan(Number(standard.dims.nodeNode));
- expect(Number(architecture.dims.nodeLayer)).toBeGreaterThan(Number(standard.dims.nodeLayer));
- expect(Number(architecture.dims.component)).toBeGreaterThan(Number(standard.dims.component));
+ // Architecture enforces a minimum spacing floor, so it is >= normal, not strictly greater.
+ expect(Number(architecture.dims.nodeNode)).toBeGreaterThanOrEqual(Number(standard.dims.nodeNode));
+ expect(Number(architecture.dims.nodeLayer)).toBeGreaterThanOrEqual(Number(standard.dims.nodeLayer));
+ expect(Number(architecture.dims.component)).toBeGreaterThanOrEqual(Number(standard.dims.component));
expect(architecture.layoutOptions['elk.layered.nodePlacement.strategy']).toBe('BRANDES_KOEPF');
expect(architecture.layoutOptions['elk.spacing.edgeNode']).toBe('24');
expect(architecture.layoutOptions['elk.spacing.edgeEdge']).toBe('18');
expect(architecture.layoutOptions['elk.layered.spacing.edgeEdgeBetweenLayers']).toBe('42');
- expect(architecture.layoutOptions['elk.layered.nodePlacement.bk.fixedAlignment']).toBe('BALANCED');
+ expect(architecture.layoutOptions['elk.layered.nodePlacement.bk.fixedAlignment']).toBe(
+ 'BALANCED'
+ );
+ });
+
+ it('enables compound hierarchy handling and shared root padding', () => {
+ const config = buildResolvedLayoutConfiguration({
+ algorithm: 'layered',
+ direction: 'TB',
+ spacing: 'normal',
+ });
+
+ expect(config.layoutOptions['elk.hierarchyHandling']).toBe('INCLUDE_CHILDREN');
+ expect(config.layoutOptions['elk.padding']).toBe('[top=16,left=20,bottom=32,right=20]');
+ });
+});
+
+describe('normalizeParentedElkPositions', () => {
+ it('converts child positions from absolute ELK coordinates to parent-relative flow coordinates', () => {
+ const nodes = [
+ {
+ id: 'section-1',
+ type: 'section',
+ position: { x: 0, y: 0 },
+ data: { label: 'Section' },
+ style: { width: 500, height: 400 },
+ } as FlowNode,
+ createNode('child-1', 'section-1'),
+ createNode('child-2'),
+ ];
+
+ const absolutePositionMap = createPositionMap([
+ ['section-1', { x: 120, y: 80, width: 560, height: 420 }],
+ ['child-1', { x: 200, y: 150, width: 120, height: 60 }],
+ ['child-2', { x: 700, y: 500, width: 120, height: 60 }],
+ ]);
+
+ const normalized = normalizeParentedElkPositions(nodes, absolutePositionMap);
+
+ expect(normalized.get('section-1')).toEqual({ x: 120, y: 80, width: 560, height: 420 });
+ expect(normalized.get('child-1')).toEqual({ x: 80, y: 70, width: 120, height: 60 });
+ expect(normalized.get('child-2')).toEqual({ x: 700, y: 500, width: 120, height: 60 });
+ });
+});
+
+describe('applyElkLayoutToNodes', () => {
+ it('updates section size while preserving parent-relative child coordinates', () => {
+ const section = {
+ id: 'section-1',
+ type: 'section',
+ position: { x: 0, y: 0 },
+ data: { label: 'Section' },
+ style: { width: 500, height: 400 },
+ } as FlowNode;
+ const child = {
+ ...createNode('child-1', 'section-1'),
+ position: { x: 0, y: 0 },
+ style: { width: 120, height: 60 },
+ } as FlowNode;
+
+ const laidOutNodes = applyElkLayoutToNodes(
+ [section, child],
+ createPositionMap([
+ ['section-1', { x: 120, y: 80, width: 560, height: 420 }],
+ ['child-1', { x: 200, y: 150, width: 120, height: 60 }],
+ ])
+ );
+
+ expect(laidOutNodes.find((node) => node.id === 'section-1')?.position).toEqual({
+ x: 120,
+ y: 80,
+ });
+ expect(laidOutNodes.find((node) => node.id === 'section-1')?.style).toMatchObject({
+ width: 560,
+ height: 420,
+ });
+ expect(laidOutNodes.find((node) => node.id === 'child-1')?.position).toEqual({ x: 80, y: 70 });
});
});
@@ -228,9 +309,27 @@ describe('normalizeElkEdgeBoundaryFanout', () => {
{ id: 'e3', source: 'source', target: 'c', sourceHandle: 'right' },
] as FlowEdge[];
const edgePointsMap = new Map([
- ['e1', [{ x: 200, y: 60 }, { x: 260, y: 60 }]],
- ['e2', [{ x: 200, y: 60 }, { x: 260, y: 60 }]],
- ['e3', [{ x: 200, y: 60 }, { x: 260, y: 60 }]],
+ [
+ 'e1',
+ [
+ { x: 200, y: 60 },
+ { x: 260, y: 60 },
+ ],
+ ],
+ [
+ 'e2',
+ [
+ { x: 200, y: 60 },
+ { x: 260, y: 60 },
+ ],
+ ],
+ [
+ 'e3',
+ [
+ { x: 200, y: 60 },
+ { x: 260, y: 60 },
+ ],
+ ],
]);
const positionMap = createPositionMap([
['source', { x: 0, y: 0, width: 200, height: 120 }],
@@ -294,9 +393,27 @@ describe('normalizeElkEdgeBoundaryFanout', () => {
{ id: 'e3', source: 'source', target: 'c', sourceHandle: 'bottom' },
] as FlowEdge[];
const edgePointsMap = new Map([
- ['e1', [{ x: 100, y: 120 }, { x: 100, y: 180 }]],
- ['e2', [{ x: 100, y: 120 }, { x: 100, y: 180 }]],
- ['e3', [{ x: 100, y: 120 }, { x: 100, y: 180 }]],
+ [
+ 'e1',
+ [
+ { x: 100, y: 120 },
+ { x: 100, y: 180 },
+ ],
+ ],
+ [
+ 'e2',
+ [
+ { x: 100, y: 120 },
+ { x: 100, y: 180 },
+ ],
+ ],
+ [
+ 'e3',
+ [
+ { x: 100, y: 120 },
+ { x: 100, y: 180 },
+ ],
+ ],
]);
const positionMap = createPositionMap([
['source', { x: 0, y: 0, width: 200, height: 120 }],
@@ -329,14 +446,18 @@ describe('normalizeElkEdgeBoundaryFanout', () => {
height: 60,
data: { label: 'Source' },
} as FlowNode,
- ...Array.from({ length: 5 }, (_, index) => ({
- id: `target-${index}`,
- type: 'process',
- position: { x: 280, y: index * 40 },
- width: 120,
- height: 80,
- data: { label: `Target ${index}` },
- } as FlowNode)),
+ ...Array.from(
+ { length: 5 },
+ (_, index) =>
+ ({
+ id: `target-${index}`,
+ type: 'process',
+ position: { x: 280, y: index * 40 },
+ width: 120,
+ height: 80,
+ data: { label: `Target ${index}` },
+ }) as FlowNode
+ ),
];
const edges = Array.from({ length: 5 }, (_, index) => ({
id: `e${index}`,
@@ -345,14 +466,26 @@ describe('normalizeElkEdgeBoundaryFanout', () => {
sourceHandle: 'right',
})) as FlowEdge[];
const edgePointsMap = new Map(
- edges.map((edge) => [edge.id, [{ x: 180, y: 30 }, { x: 240, y: 30 }]])
+ edges.map((edge) => [
+ edge.id,
+ [
+ { x: 180, y: 30 },
+ { x: 240, y: 30 },
+ ],
+ ])
);
- const positionMapEntries: Array<[string, { x: number; y: number; width?: number; height?: number }]> = [
+ const positionMapEntries: Array<
+ [string, { x: number; y: number; width?: number; height?: number }]
+ > = [
['source', { x: 0, y: 0, width: 180, height: 60 }],
- ...Array.from({ length: 5 }, (_, index) => ([
- `target-${index}`,
- { x: 280, y: index * 40, width: 120, height: 80 },
- ] as [string, { x: number; y: number; width?: number; height?: number }])),
+ ...Array.from(
+ { length: 5 },
+ (_, index) =>
+ [`target-${index}`, { x: 280, y: index * 40, width: 120, height: 80 }] as [
+ string,
+ { x: number; y: number; width?: number; height?: number },
+ ]
+ ),
];
const positionMap = createPositionMap(positionMapEntries);
@@ -451,6 +584,39 @@ describe('resolveLayoutedEdgeHandles', () => {
});
});
+describe('resolveAutomaticLayoutAlgorithm', () => {
+ it('prefers tree layout for high-branching acyclic graphs', () => {
+ const nodes = ['root', 'a', 'b', 'c', 'd', 'e'].map((id) => createNode(id));
+ const edges = ['a', 'b', 'c', 'd', 'e'].map((id, index) => createEdge(`e${index}`, 'root', id));
+
+ expect(resolveAutomaticLayoutAlgorithm(nodes, edges, { diagramType: 'flowchart' })).toBe(
+ 'mrtree'
+ );
+ });
+
+ it('switches cyclic graphs away from layered layout automatically', () => {
+ const nodes = ['a', 'b', 'c'].map((id) => createNode(id));
+ const edges = [
+ createEdge('e1', 'a', 'b'),
+ createEdge('e2', 'b', 'c'),
+ createEdge('e3', 'c', 'a'),
+ ];
+
+ expect(resolveAutomaticLayoutAlgorithm(nodes, edges, { diagramType: 'flowchart' })).toBe(
+ 'force'
+ );
+ });
+
+ it('keeps architecture imports on layered layout', () => {
+ const nodes = [createNode('edge'), createNode('api')];
+ const edges = [createEdge('e1', 'edge', 'api')];
+
+ expect(resolveAutomaticLayoutAlgorithm(nodes, edges, { diagramType: 'architecture' })).toBe(
+ 'layered'
+ );
+ });
+});
+
describe('shouldUseLightweightLayoutPostProcessing', () => {
it('keeps smaller standard diagrams on the full post-processing path', () => {
expect(shouldUseLightweightLayoutPostProcessing(20, 24, 'flowchart')).toBe(false);
@@ -461,8 +627,9 @@ describe('shouldUseLightweightLayoutPostProcessing', () => {
expect(shouldUseLightweightLayoutPostProcessing(16, 72, 'flowchart')).toBe(true);
});
- it('switches architecture diagrams earlier because icon-heavy edge normalization is expensive', () => {
- expect(shouldUseLightweightLayoutPostProcessing(24, 20, 'architecture')).toBe(true);
- expect(shouldUseLightweightLayoutPostProcessing(12, 36, 'infrastructure')).toBe(true);
+ it('switches architecture diagrams to lightweight post-processing for large graphs', () => {
+ expect(shouldUseLightweightLayoutPostProcessing(40, 20, 'architecture')).toBe(true);
+ expect(shouldUseLightweightLayoutPostProcessing(12, 60, 'infrastructure')).toBe(true);
+ expect(shouldUseLightweightLayoutPostProcessing(24, 20, 'architecture')).toBe(false);
});
});
diff --git a/src/services/elkLayout.ts b/src/services/elkLayout.ts
index eed6b115..ab02fd70 100644
--- a/src/services/elkLayout.ts
+++ b/src/services/elkLayout.ts
@@ -1,17 +1,27 @@
import type { ElkExtendedEdge, ElkNode } from 'elkjs/lib/elk.bundled.js';
import { NODE_HEIGHT, NODE_WIDTH } from '@/constants';
import { getIconAssetNodeMinSize, resolveNodeSize } from '@/components/nodeHelpers';
+import {
+ SECTION_CONTENT_PADDING_TOP,
+ SECTION_MIN_HEIGHT,
+ SECTION_MIN_WIDTH,
+ SECTION_PADDING_BOTTOM,
+ SECTION_PADDING_X,
+} from '@/hooks/node-operations/sectionBounds';
import { createLogger } from '@/lib/logger';
+import { getNodeParentId } from '@/lib/nodeParent';
import type { FlowEdge, FlowNode } from '@/lib/types';
-import { assignSmartHandlesWithOptions } from './smartEdgeRouting';
+import { assignSmartHandlesWithOptions, handleSideFromVector } from './smartEdgeRouting';
import { normalizeLayoutInputsForDeterminism } from './elk-layout/determinism';
-import { normalizeElkEdgeBoundaryFanout } from './elk-layout/boundaryFanout';
+import { normalizeElkEdgeBoundaryFanout, type NodeBounds } from './elk-layout/boundaryFanout';
import {
buildResolvedLayoutConfiguration,
getDeterministicSeedOptions,
resolveLayoutPresetOptions,
} from './elk-layout/options';
import type { FlowNodeWithMeasuredDimensions, LayoutOptions } from './elk-layout/types';
+import { estimateWrappedTextBox, DEFAULT_MAX_WIDTH } from './elk-layout/textSizing';
+import { getNodeHandleIdForSide } from '@/lib/nodeHandles';
interface ElkLayoutEngine {
layout: (graph: ElkNode) => Promise;
@@ -25,14 +35,38 @@ let elkInstancePromise: Promise | null = null;
const LARGE_DIAGRAM_NODE_THRESHOLD = 48;
const LARGE_DIAGRAM_EDGE_THRESHOLD = 72;
const logger = createLogger({ scope: 'elkLayout' });
-const SEMANTIC_LAYER_ORDER = ['edge', 'frontend', 'api', 'services', 'data', 'external'] as const;
+const FALLBACK_LAYER_ORDER = ['edge', 'frontend', 'api', 'services', 'data', 'external'] as const;
+
+const FALLBACK_LAYER_KEYWORDS: ReadonlyArray<{
+ layer: (typeof FALLBACK_LAYER_ORDER)[number];
+ keywords: string[];
+}> = [
+ { layer: 'edge', keywords: ['edge', 'gateway', 'cdn'] },
+ { layer: 'frontend', keywords: ['frontend', 'browser', 'web', 'mobile'] },
+ { layer: 'api', keywords: ['api'] },
+ { layer: 'services', keywords: ['service', 'compute', 'worker', 'backend'] },
+ { layer: 'data', keywords: ['data', 'database', 'cache', 'storage'] },
+ { layer: 'external', keywords: ['external', 'third-party', 'third party'] },
+];
+
+const ELK_SECTION_PADDING = `[top=${SECTION_CONTENT_PADDING_TOP},left=${SECTION_PADDING_X},bottom=${SECTION_PADDING_BOTTOM},right=${SECTION_PADDING_X}]`;
+const ELK_COMPOUND_LAYOUT_OPTIONS = {
+ 'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
+ 'elk.algorithm': 'layered',
+} as const;
const layoutCache = new Map();
const LAYOUT_CACHE_MAX = 20;
function getLayoutCacheKey(nodes: FlowNode[], edges: FlowEdge[], options: LayoutOptions): string {
- const nodeStr = nodes.map((n) => n.id).sort().join(',');
- const edgeStr = edges.map((e) => `${e.source}>${e.target}`).sort().join(',');
+ const nodeStr = nodes
+ .map((n) => n.id)
+ .sort()
+ .join(',');
+ const edgeStr = edges
+ .map((e) => `${e.source}>${e.target}`)
+ .sort()
+ .join(',');
return `${nodeStr}|${edgeStr}|${options.direction ?? 'TB'}:${options.algorithm ?? 'layered'}:${options.spacing ?? 'normal'}:${options.diagramType ?? ''}`;
}
@@ -64,7 +98,51 @@ async function getElkInstance(): Promise {
return elkInstancePromise;
}
-function buildElkNode(node: FlowNode, childrenByParent: Map): ElkNode {
+const IMPORT_NODE_MIN_WIDTH = 160;
+const IMPORT_NODE_MIN_HEIGHT = 52;
+const IMPORT_NODE_MAX_WIDTH = 240;
+
+function estimateNodeSize(
+ node: FlowNode,
+ nodeMinWidth: number,
+ nodeMinHeight: number
+): { width: number; height: number } {
+ const isImportSized = nodeMinWidth < NODE_WIDTH;
+ const estimate = estimateWrappedTextBox(String(node.data?.label ?? ''), {
+ minWidth: nodeMinWidth,
+ minHeight: nodeMinHeight,
+ maxWidth: isImportSized ? IMPORT_NODE_MAX_WIDTH : DEFAULT_MAX_WIDTH,
+ });
+
+ // Only apply resolvedSize as a floor when it represents an explicit user-set
+ // dimension, not the canvas default. For import-sized nodes, resolveNodeSize()
+ // returns 250px which would silently defeat import compaction.
+ if (isImportSized) {
+ return estimate;
+ }
+
+ const resolvedSize = resolveNodeSize(node);
+ return {
+ width: Math.max(resolvedSize.width, estimate.width),
+ height: Math.max(resolvedSize.height, estimate.height),
+ };
+}
+
+function hasInternalEdges(
+ childIds: Set,
+ edges: FlowEdge[]
+): boolean {
+ return edges.some((e) => childIds.has(e.source) && childIds.has(e.target));
+}
+
+function buildElkNode(
+ node: FlowNode,
+ childrenByParent: Map,
+ allEdges: FlowEdge[],
+ nodeMinWidth = NODE_WIDTH,
+ nodeMinHeight = NODE_HEIGHT,
+ rootElkDirection = 'DOWN'
+): ElkNode {
const children = childrenByParent.get(node.id) || [];
const nodeWithMeasuredDimensions = node as FlowNodeWithMeasuredDimensions;
@@ -77,108 +155,97 @@ function buildElkNode(node: FlowNode, childrenByParent: Map)
width = width ?? minSize.minWidth;
height = height ?? minSize.minHeight;
} else {
- const resolvedSize = resolveNodeSize(node);
- const label = node.data?.label || '';
- const estimatedWidth = Math.max(resolvedSize.width, NODE_WIDTH, label.length * 8 + 40);
- const estimatedHeight = Math.max(
- resolvedSize.height,
- NODE_HEIGHT,
- Math.ceil(label.length / 40) * 20 + 60
- );
-
- width = width ?? estimatedWidth;
- height = height ?? estimatedHeight;
+ const estimatedSize = estimateNodeSize(node, nodeMinWidth, nodeMinHeight);
+ width = width ?? estimatedSize.width;
+ height = height ?? estimatedSize.height;
}
}
+ const hasChildren = children.length > 0;
+
+ // Subgraphs with no internal edges (pure parallel siblings) lay out
+ // horizontally regardless of root direction โ matching Mermaid's Dagre
+ // which places same-rank disconnected nodes side by side.
+ const childIds = hasChildren ? new Set(children.map((c) => c.id)) : null;
+ const parallelChildren = childIds !== null && !hasInternalEdges(childIds, allEdges);
+
+ // Compound nodes must explicitly inherit the root elk.direction โ ELK does
+ // not cascade it automatically, so without this subgraphs always use ELK's
+ // built-in default (DOWN) regardless of the root graph setting.
+ const compoundLayoutOptions = hasChildren
+ ? {
+ ...ELK_COMPOUND_LAYOUT_OPTIONS,
+ 'elk.direction': parallelChildren ? 'RIGHT' : rootElkDirection,
+ }
+ : {};
+
return {
id: node.id,
- width: children.length === 0 ? width : undefined,
- height: children.length === 0 ? height : undefined,
- children: children.map((child) => buildElkNode(child, childrenByParent)),
+ width: hasChildren ? undefined : width,
+ height: hasChildren ? undefined : height,
+ children: children.map((child) =>
+ buildElkNode(child, childrenByParent, allEdges, nodeMinWidth, nodeMinHeight, rootElkDirection)
+ ),
layoutOptions: {
- 'elk.padding': '[top=40,left=20,bottom=20,right=20]',
+ 'elk.padding': ELK_SECTION_PADDING,
+ ...compoundLayoutOptions,
},
};
}
-function inferSemanticLayerRank(node: FlowNode): number | null {
+const SECTION_TYPES = new Set(['section', 'group', 'browser', 'mobile']);
+
+function buildDynamicLayerOrder(nodes: FlowNode[]): readonly string[] {
+ const sections = nodes.filter((n) => SECTION_TYPES.has(String(n.type)));
+ if (sections.length === 0) return FALLBACK_LAYER_ORDER;
+ return sections.map((n) => String(n.data?.label ?? n.id).toLowerCase());
+}
+
+function inferSemanticLayerRank(node: FlowNode, dynamicOrder: readonly string[]): number | null {
+ if (typeof node.data?.archLayerRank === 'number' && Number.isFinite(node.data.archLayerRank)) {
+ return node.data.archLayerRank;
+ }
+
const label = String(node.data?.label ?? '').toLowerCase();
const subLabel = String(node.data?.subLabel ?? '').toLowerCase();
const type = String(node.type ?? '').toLowerCase();
const haystack = `${label} ${subLabel} ${type}`;
- if (haystack.includes('edge') || haystack.includes('gateway') || haystack.includes('cdn')) {
- return SEMANTIC_LAYER_ORDER.indexOf('edge');
- }
- if (
- haystack.includes('frontend') ||
- haystack.includes('browser') ||
- haystack.includes('web') ||
- haystack.includes('mobile')
- ) {
- return SEMANTIC_LAYER_ORDER.indexOf('frontend');
- }
- if (haystack.includes('api')) {
- return SEMANTIC_LAYER_ORDER.indexOf('api');
- }
- if (
- haystack.includes('service') ||
- haystack.includes('compute') ||
- haystack.includes('worker') ||
- haystack.includes('backend')
- ) {
- return SEMANTIC_LAYER_ORDER.indexOf('services');
- }
- if (
- haystack.includes('data') ||
- haystack.includes('database') ||
- haystack.includes('cache') ||
- haystack.includes('storage')
- ) {
- return SEMANTIC_LAYER_ORDER.indexOf('data');
- }
- if (
- haystack.includes('external') ||
- haystack.includes('third-party') ||
- haystack.includes('third party')
- ) {
- return SEMANTIC_LAYER_ORDER.indexOf('external');
- }
+ const dynamicRank = dynamicOrder.findIndex((layer) => haystack.includes(layer));
+ if (dynamicRank !== -1) return dynamicRank;
- return null;
+ const fallbackMatch = FALLBACK_LAYER_KEYWORDS.find(({ keywords }) =>
+ keywords.some((kw) => haystack.includes(kw))
+ );
+ return fallbackMatch ? FALLBACK_LAYER_ORDER.indexOf(fallbackMatch.layer) : null;
}
function isArchitectureLikeNode(node: FlowNode): boolean {
- if (node.type === 'architecture') {
- return true;
- }
-
- return inferSemanticLayerRank(node) !== null || ['browser', 'mobile', 'section', 'group'].includes(String(node.type));
+ if (node.type === 'architecture') return true;
+ return (
+ inferSemanticLayerRank(node, FALLBACK_LAYER_ORDER) !== null ||
+ SECTION_TYPES.has(String(node.type))
+ );
}
function resolveEffectiveDiagramType(nodes: FlowNode[], diagramType?: string): string | undefined {
- if (diagramType) {
- return diagramType;
- }
-
+ if (diagramType) return diagramType;
return nodes.some(isArchitectureLikeNode) ? 'architecture' : undefined;
}
-function sortTopLevelNodesForArchitecture(topLevelNodes: FlowNode[]): FlowNode[] {
+function sortTopLevelNodesForArchitecture(
+ topLevelNodes: FlowNode[],
+ dynamicOrder: readonly string[]
+): FlowNode[] {
+ const rankCache = new Map(
+ topLevelNodes.map((n) => [n.id, inferSemanticLayerRank(n, dynamicOrder)])
+ );
return [...topLevelNodes].sort((left, right) => {
- const leftRank = inferSemanticLayerRank(left);
- const rightRank = inferSemanticLayerRank(right);
-
- if (leftRank === null && rightRank === null) {
- return 0;
- }
- if (leftRank === null) {
- return 1;
- }
- if (rightRank === null) {
- return -1;
- }
+ const leftRank = rankCache.get(left.id) ?? null;
+ const rightRank = rankCache.get(right.id) ?? null;
+ if (leftRank === null && rightRank === null) return 0;
+ if (leftRank === null) return 1;
+ if (rightRank === null) return -1;
return leftRank - rightRank;
});
}
@@ -204,6 +271,271 @@ function buildPositionMap(
return positionMap;
}
+export function normalizeParentedElkPositions(
+ nodes: FlowNode[],
+ absolutePositionMap: Map
+): Map {
+ const normalizedPositionMap = new Map(absolutePositionMap);
+
+ for (const node of nodes) {
+ const parentId = getNodeParentId(node);
+ if (!parentId) {
+ continue;
+ }
+
+ const childPosition = absolutePositionMap.get(node.id);
+ const parentPosition = absolutePositionMap.get(parentId);
+ if (!childPosition || !parentPosition) {
+ continue;
+ }
+
+ normalizedPositionMap.set(node.id, {
+ ...childPosition,
+ x: childPosition.x - parentPosition.x,
+ y: childPosition.y - parentPosition.y,
+ });
+ }
+
+ return normalizedPositionMap;
+}
+
+export function applyElkLayoutToNodes(
+ nodes: FlowNode[],
+ absolutePositionMap: Map
+): FlowNode[] {
+ const normalizedPositionMap = normalizeParentedElkPositions(nodes, absolutePositionMap);
+
+ return nodes.map((node) => {
+ const normalizedPosition = normalizedPositionMap.get(node.id);
+ if (!normalizedPosition) {
+ return node;
+ }
+
+ const style = { ...node.style };
+ if (node.type === 'group' || node.type === 'section' || node.type === 'container') {
+ if (normalizedPosition.width) {
+ style.width = normalizedPosition.width;
+ }
+ if (normalizedPosition.height) {
+ style.height = normalizedPosition.height;
+ }
+ }
+
+ return {
+ ...node,
+ position: { x: normalizedPosition.x, y: normalizedPosition.y },
+ style,
+ };
+ });
+}
+
+function getNodeBoundsFromPositionMap(
+ node: FlowNode,
+ positionMap: Map
+): NodeBounds {
+ const pos = positionMap.get(node.id);
+ const x = pos?.x ?? node.position.x;
+ const y = pos?.y ?? node.position.y;
+ const measured = (node as FlowNodeWithMeasuredDimensions).measured;
+ const needsEstimate = !pos?.width || !pos?.height;
+ const estimate = needsEstimate ? estimateNodeSize(node, NODE_WIDTH, NODE_HEIGHT) : null;
+ const width = pos?.width ?? measured?.width ?? estimate!.width;
+ const height = pos?.height ?? measured?.height ?? estimate!.height;
+ return {
+ left: x,
+ right: x + width,
+ top: y,
+ bottom: y + height,
+ centerX: x + width / 2,
+ centerY: y + height / 2,
+ };
+}
+
+function getFallbackNodeSize(
+ node: FlowNode,
+ nodeMinWidth: number,
+ nodeMinHeight: number
+): { width: number; height: number } {
+ const measured = node as FlowNodeWithMeasuredDimensions;
+ if (measured.measured?.width && measured.measured?.height) {
+ return {
+ width: measured.measured.width,
+ height: measured.measured.height,
+ };
+ }
+
+ if (node.data?.assetPresentation === 'icon') {
+ const minSize = getIconAssetNodeMinSize(Boolean(node.data?.label?.trim()));
+ return { width: minSize.minWidth, height: minSize.minHeight };
+ }
+
+ return estimateNodeSize(node, nodeMinWidth, nodeMinHeight);
+}
+
+function getFallbackSpacing(options: LayoutOptions): { primary: number; secondary: number } {
+ const isImport = options.source === 'import';
+ const primaryBase = isImport ? 36 : 56;
+ const secondaryBase = isImport ? 52 : 84;
+
+ switch (options.contentDensity) {
+ case 'compact':
+ return { primary: primaryBase - 8, secondary: secondaryBase - 10 };
+ case 'verbose':
+ return { primary: primaryBase + 10, secondary: secondaryBase + 14 };
+ default:
+ return { primary: primaryBase, secondary: secondaryBase };
+ }
+}
+
+function applyRecursiveFallbackLayout(
+ nodes: FlowNode[],
+ options: LayoutOptions,
+ nodeMinWidth: number,
+ nodeMinHeight: number
+): FlowNode[] {
+ const { topLevelNodes, childrenByParent } = normalizeLayoutInputsForDeterminism(nodes, []);
+ const positionedNodes = new Map();
+ const isHorizontal = options.direction === 'LR' || options.direction === 'RL';
+ const spacing = getFallbackSpacing(options);
+
+ function layoutNode(
+ node: FlowNode,
+ origin: { x: number; y: number }
+ ): { width: number; height: number } {
+ const directChildren = childrenByParent.get(node.id) ?? [];
+ const hasChildren = directChildren.length > 0;
+ const nextNode: FlowNode = {
+ ...node,
+ position: origin,
+ };
+
+ if (!hasChildren) {
+ positionedNodes.set(node.id, nextNode);
+ return getFallbackNodeSize(node, nodeMinWidth, nodeMinHeight);
+ }
+
+ let cursorX = SECTION_PADDING_X;
+ let cursorY = SECTION_CONTENT_PADDING_TOP;
+ let maxChildRight = cursorX;
+ let maxChildBottom = cursorY;
+
+ for (const child of directChildren) {
+ const childBounds = layoutNode(child, { x: cursorX, y: cursorY });
+ maxChildRight = Math.max(maxChildRight, cursorX + childBounds.width);
+ maxChildBottom = Math.max(maxChildBottom, cursorY + childBounds.height);
+
+ if (isHorizontal) {
+ cursorX += childBounds.width + spacing.primary;
+ } else {
+ cursorY += childBounds.height + spacing.primary;
+ }
+ }
+
+ const width = Math.max(maxChildRight + SECTION_PADDING_X, SECTION_MIN_WIDTH);
+ const height = Math.max(maxChildBottom + SECTION_PADDING_BOTTOM, SECTION_MIN_HEIGHT);
+
+ positionedNodes.set(node.id, {
+ ...nextNode,
+ style:
+ node.type === 'group' || node.type === 'section' || node.type === 'container'
+ ? {
+ ...node.style,
+ width,
+ height,
+ }
+ : node.style,
+ });
+
+ return { width, height };
+ }
+
+ let cursorX = 0;
+ let cursorY = 0;
+ for (const node of topLevelNodes) {
+ const laidOutSize = layoutNode(node, { x: cursorX, y: cursorY });
+ if (isHorizontal) {
+ cursorX += laidOutSize.width + spacing.secondary;
+ } else {
+ cursorY += laidOutSize.height + spacing.secondary;
+ }
+ }
+
+ return nodes.map((node) => positionedNodes.get(node.id) ?? node);
+}
+
+function inferHandleSideFromPoint(
+ bounds: NodeBounds,
+ point: { x: number; y: number },
+ adjacentPoint?: { x: number; y: number }
+): 'left' | 'right' | 'top' | 'bottom' {
+ const dx = adjacentPoint ? adjacentPoint.x - point.x : point.x - bounds.centerX;
+ const dy = adjacentPoint ? adjacentPoint.y - point.y : point.y - bounds.centerY;
+ return handleSideFromVector(dx, dy);
+}
+
+function staggerParallelEdgeLabels(edges: FlowEdge[]): FlowEdge[] {
+ if (!edges.some((e) => e.label)) return edges;
+
+ const pairCounts = new Map();
+ const pairIndex = new Map();
+
+ for (const edge of edges) {
+ const key = [edge.source, edge.target].sort().join('|');
+ pairCounts.set(key, (pairCounts.get(key) ?? 0) + 1);
+ }
+
+ return edges.map((edge) => {
+ const key = [edge.source, edge.target].sort().join('|');
+ const count = pairCounts.get(key) ?? 1;
+ if (count <= 1 || !edge.label) return edge;
+
+ const idx = pairIndex.get(key) ?? 0;
+ pairIndex.set(key, idx + 1);
+
+ // Spread labels across 0.3โ0.7 range to avoid pile-up at the midpoint.
+ const spread = 0.4;
+ const labelPosition = 0.5 + spread * ((idx / (count - 1)) - 0.5);
+
+ return {
+ ...edge,
+ data: {
+ ...edge.data,
+ labelPosition: edge.data?.labelPosition ?? labelPosition,
+ },
+ };
+ });
+}
+
+function applyElkHandles(
+ edges: FlowEdge[],
+ nodes: FlowNode[],
+ positionMap: Map,
+ edgePointsMap: Map
+): FlowEdge[] {
+ const nodeMap = new Map(nodes.map((n) => [n.id, n]));
+ const routed = edges.map((edge) => {
+ if (edge.source === edge.target) return edge;
+ const points = edgePointsMap.get(edge.id);
+ if (!points || points.length < 2) return edge;
+ const sourceNode = nodeMap.get(edge.source);
+ const targetNode = nodeMap.get(edge.target);
+ if (!sourceNode || !targetNode) return edge;
+ const sourceBounds = getNodeBoundsFromPositionMap(sourceNode, positionMap);
+ const targetBounds = getNodeBoundsFromPositionMap(targetNode, positionMap);
+ const sourceSide = inferHandleSideFromPoint(sourceBounds, points[0], points[1]);
+ const targetSide = inferHandleSideFromPoint(
+ targetBounds,
+ points[points.length - 1],
+ points[points.length - 2]
+ );
+ const sourceHandle = getNodeHandleIdForSide(sourceNode, sourceSide);
+ const targetHandle = getNodeHandleIdForSide(targetNode, targetSide);
+ if (edge.sourceHandle === sourceHandle && edge.targetHandle === targetHandle) return edge;
+ return { ...edge, sourceHandle, targetHandle };
+ });
+ return staggerParallelEdgeLabels(routed);
+}
+
export type { LayoutAlgorithm, LayoutDirection, LayoutOptions } from './elk-layout/types';
export {
buildResolvedLayoutConfiguration,
@@ -241,6 +573,92 @@ function isSparseDiagram(nodeCount: number, edgeCount: number): boolean {
return avgDegree <= 2.5;
}
+function detectCycles(nodes: FlowNode[], edges: FlowEdge[]): boolean {
+ const adjacency = new Map();
+ const visiting = new Set();
+ const visited = new Set();
+
+ nodes.forEach((node) => adjacency.set(node.id, []));
+ edges.forEach((edge) => {
+ if (!adjacency.has(edge.source)) {
+ adjacency.set(edge.source, []);
+ }
+ adjacency.get(edge.source)?.push(edge.target);
+ });
+
+ function visit(nodeId: string): boolean {
+ if (visiting.has(nodeId)) {
+ return true;
+ }
+ if (visited.has(nodeId)) {
+ return false;
+ }
+
+ visiting.add(nodeId);
+ for (const nextId of adjacency.get(nodeId) ?? []) {
+ if (visit(nextId)) {
+ return true;
+ }
+ }
+ visiting.delete(nodeId);
+ visited.add(nodeId);
+ return false;
+ }
+
+ for (const nodeId of adjacency.keys()) {
+ if (visit(nodeId)) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+function getMaxBranchingFactor(edges: FlowEdge[]): number {
+ const counts = new Map();
+ let max = 0;
+ for (const edge of edges) {
+ const count = (counts.get(edge.source) ?? 0) + 1;
+ counts.set(edge.source, count);
+ if (count > max) max = count;
+ }
+ return max;
+}
+
+export function resolveAutomaticLayoutAlgorithm(
+ nodes: FlowNode[],
+ edges: FlowEdge[],
+ options: LayoutOptions = {}
+): LayoutOptions['algorithm'] {
+ if (options.algorithm) {
+ return options.algorithm;
+ }
+
+ if (options.diagramType === 'architecture' || options.diagramType === 'infrastructure') {
+ return 'layered';
+ }
+
+ const nodeCount = nodes.length;
+ const edgeCount = edges.length;
+ if (nodeCount <= 1 || edgeCount === 0) {
+ return 'layered';
+ }
+
+ const density = edgeCount / Math.max(nodeCount * (nodeCount - 1), 1);
+ const hasCycles = detectCycles(nodes, edges);
+ const maxBranchingFactor = getMaxBranchingFactor(edges);
+
+ if (!hasCycles && maxBranchingFactor > 4 && edgeCount >= nodeCount - 1) {
+ return 'mrtree';
+ }
+
+ if (density > 0.15 || hasCycles) {
+ return nodeCount >= 24 ? 'stress' : 'force';
+ }
+
+ return 'layered';
+}
+
export function shouldUseLightweightLayoutPostProcessing(
nodeCount: number,
edgeCount: number,
@@ -255,7 +673,7 @@ export function shouldUseLightweightLayoutPostProcessing(
return false;
}
- return nodeCount >= 24 || edgeCount >= 36;
+ return nodeCount >= 40 || edgeCount >= 60;
}
export async function getElkLayout(
@@ -263,10 +681,6 @@ export async function getElkLayout(
edges: FlowEdge[],
options: LayoutOptions = {}
): Promise<{ nodes: FlowNode[]; edges: FlowEdge[] }> {
- const cacheKey = getLayoutCacheKey(nodes, edges, options);
- const cached = layoutCache.get(cacheKey);
- if (cached) return cached;
-
function collectEdgePoints(
elkNode: ElkNode | (ElkNode & { edges?: ElkExtendedEdge[]; children?: ElkNode[] }),
edgePointsMap: Map
@@ -290,8 +704,20 @@ export async function getElkLayout(
}
const effectiveDiagramType = resolveEffectiveDiagramType(nodes, options.diagramType);
+ const algorithm = resolveAutomaticLayoutAlgorithm(nodes, edges, {
+ ...options,
+ diagramType: effectiveDiagramType,
+ });
+ const cacheKey = getLayoutCacheKey(nodes, edges, {
+ ...options,
+ algorithm,
+ diagramType: effectiveDiagramType,
+ });
+ const cached = layoutCache.get(cacheKey);
+ if (cached) return cached;
const { layoutOptions } = buildResolvedLayoutConfiguration({
...options,
+ algorithm,
diagramType: effectiveDiagramType,
});
const { topLevelNodes, childrenByParent, sortedEdges } = normalizeLayoutInputsForDeterminism(
@@ -300,13 +726,19 @@ export async function getElkLayout(
);
const orderedTopLevelNodes =
effectiveDiagramType === 'architecture' || effectiveDiagramType === 'infrastructure'
- ? sortTopLevelNodesForArchitecture(topLevelNodes)
+ ? sortTopLevelNodesForArchitecture(topLevelNodes, buildDynamicLayerOrder(nodes))
: topLevelNodes;
+ const isImport = options.source === 'import';
+ const nodeMinWidth = isImport ? IMPORT_NODE_MIN_WIDTH : NODE_WIDTH;
+ const nodeMinHeight = isImport ? IMPORT_NODE_MIN_HEIGHT : NODE_HEIGHT;
+
const elkGraph: ElkNode = {
id: 'root',
layoutOptions,
- children: orderedTopLevelNodes.map((node) => buildElkNode(node, childrenByParent)),
+ children: orderedTopLevelNodes.map((node) =>
+ buildElkNode(node, childrenByParent, sortedEdges, nodeMinWidth, nodeMinHeight, layoutOptions['elk.direction'] ?? 'DOWN')
+ ),
edges: sortedEdges.map((edge) => ({
id: edge.id,
sources: [edge.source],
@@ -324,23 +756,7 @@ export async function getElkLayout(
const edgePointsMap = new Map();
collectEdgePoints(layoutResult, edgePointsMap);
- const laidOutNodes = nodes.map((node) => {
- const position = positionMap.get(node.id);
- if (!position) return node;
-
- const style = { ...node.style };
- if (node.type === 'group' || node.type === 'section' || node.type === 'container') {
- if (position.width) style.width = position.width;
- if (position.height) style.height = position.height;
- }
-
- return {
- ...node,
- position: { x: position.x, y: position.y },
- style,
- };
- });
- const reroutedEdges = resolveLayoutedEdgeHandles(laidOutNodes, sortedEdges);
+ const laidOutNodes = applyElkLayoutToNodes(nodes, positionMap);
const sparse = isSparseDiagram(nodes.length, sortedEdges.length);
const useLightweightPostProcessing = shouldUseLightweightLayoutPostProcessing(
nodes.length,
@@ -348,6 +764,13 @@ export async function getElkLayout(
effectiveDiagramType
);
+ // For sparse/small diagrams: use smart position-based handle assignment + bezier routing.
+ // For dense diagrams: infer handles directly from ELK's computed waypoints โ more accurate.
+ const reroutedEdges =
+ sparse || useLightweightPostProcessing
+ ? resolveLayoutedEdgeHandles(laidOutNodes, sortedEdges)
+ : applyElkHandles(sortedEdges, laidOutNodes, positionMap, edgePointsMap);
+
const normalizedElkPointsMap =
sparse || useLightweightPostProcessing
? new Map()
@@ -389,7 +812,11 @@ export async function getElkLayout(
return { nodes: laidOutNodes, edges: laidOutEdges };
} catch (err) {
logger.error('ELK layout error.', { error: err });
- return { nodes, edges };
+ const fallbackNodes = applyRecursiveFallbackLayout(nodes, options, nodeMinWidth, nodeMinHeight);
+ return {
+ nodes: fallbackNodes,
+ edges: resolveLayoutedEdgeHandles(fallbackNodes, edges),
+ };
}
}
diff --git a/src/services/export/formatting.ts b/src/services/export/formatting.ts
index 667d504b..58dc360d 100644
--- a/src/services/export/formatting.ts
+++ b/src/services/export/formatting.ts
@@ -1,5 +1,9 @@
export function sanitizeLabel(label: string): string {
- return label.replace(/['"()]/g, '').trim() || 'Node';
+ return label.replace(/"/g, "'").trim() || 'Node';
+}
+
+export function sanitizeEdgeLabel(label: string): string {
+ return label.replace(/"/g, "'").replace(/[{}]/g, '').trim();
}
export function sanitizeId(id: string): string {
diff --git a/src/services/export/mermaid/architectureMermaid.ts b/src/services/export/mermaid/architectureMermaid.ts
index 1d9906ad..87809e53 100644
--- a/src/services/export/mermaid/architectureMermaid.ts
+++ b/src/services/export/mermaid/architectureMermaid.ts
@@ -1,6 +1,21 @@
import type { FlowEdge, FlowNode } from '@/lib/types';
import { handleIdToSide as handleIdToFlowSide } from '@/lib/nodeHandles';
-import { sanitizeId, sanitizeLabel } from '../formatting';
+import { sanitizeId, sanitizeLabel, sanitizeEdgeLabel } from '../formatting';
+
+const ARCHITECTURE_NODE_KINDS = new Set([
+ 'service',
+ 'person',
+ 'system',
+ 'container',
+ 'component',
+ 'database_container',
+ 'router',
+ 'switch',
+ 'firewall',
+ 'load_balancer',
+ 'cdn',
+ 'dns',
+]);
function normalizeArchitectureDirection(direction: string | undefined): '-->' | '<--' | '<-->' {
if (direction === '<--' || direction === '<-->') return direction;
@@ -25,31 +40,43 @@ function handleIdToSide(handleId: string | null | undefined): 'L' | 'R' | 'T' |
return undefined;
}
-export function toArchitectureMermaid(nodes: FlowNode[], edges: FlowEdge[]): string {
- const lines: string[] = ['architecture-beta'];
+function toArchitectureNodeStatement(node: FlowNode): string {
+ const id = sanitizeId(node.id);
+ const label = sanitizeLabel(node.data.label);
+ const kind = (node.data.archResourceType || 'service').toLowerCase();
+ const parent = node.data.archBoundaryId ? sanitizeId(node.data.archBoundaryId) : '';
+ const provider = typeof node.data.archProvider === 'string' ? node.data.archProvider : '';
+ const icon =
+ provider && provider !== 'custom' && !(kind === 'group' && provider === 'group')
+ ? `(${sanitizeLabel(provider)})`
+ : '';
+ const suffix = parent ? ` in ${parent}` : '';
- nodes.forEach((node) => {
- const id = sanitizeId(node.id);
- const label = sanitizeLabel(node.data.label);
- const kind = (node.data.archResourceType || 'service').toLowerCase();
- const parent = node.data.archBoundaryId ? sanitizeId(node.data.archBoundaryId) : '';
- const icon =
- node.data.archProvider && node.data.archProvider !== 'custom'
- ? `(${sanitizeLabel(node.data.archProvider)})`
- : '';
- const suffix = parent ? ` in ${parent}` : '';
+ if (kind === 'group') {
+ return ` group ${id}${icon}[${label}]${suffix}`;
+ }
- if (kind === 'group') {
- lines.push(` group ${id}[${label}]`);
- return;
- }
+ if (kind === 'junction') {
+ return ` junction ${id}${icon}[${label}]${suffix}`;
+ }
+ const statementKind = ARCHITECTURE_NODE_KINDS.has(kind) ? kind : 'service';
- if (kind === 'junction') {
- lines.push(` junction ${id}${icon}[${label}]${suffix}`);
- return;
- }
+ return ` ${statementKind} ${id}${icon}[${label}]${suffix}`;
+}
+
+export function toArchitectureMermaid(nodes: FlowNode[], edges: FlowEdge[]): string {
+ const lines: string[] = ['architecture-beta'];
+ const titleNode = nodes.find(
+ (node) => typeof node.data.archTitle === 'string' && node.data.archTitle.trim().length > 0
+ );
+ const title = typeof titleNode?.data.archTitle === 'string' ? sanitizeLabel(titleNode.data.archTitle) : '';
- lines.push(` service ${id}${icon}[${label}]${suffix}`);
+ if (title) {
+ lines.push(` title "${title}"`);
+ }
+
+ nodes.forEach((node) => {
+ lines.push(toArchitectureNodeStatement(node));
});
edges.forEach((edge) => {
@@ -66,7 +93,7 @@ export function toArchitectureMermaid(nodes: FlowNode[], edges: FlowEdge[]): str
| undefined;
const protocol = edgeData?.archProtocol;
const port = edgeData?.archPort;
- const label = edge.label ? sanitizeLabel(String(edge.label)) : undefined;
+ const label = edge.label ? sanitizeEdgeLabel(String(edge.label)) : undefined;
const sourceSide =
normalizeArchitectureSide(edgeData?.archSourceSide) || handleIdToSide(edge.sourceHandle);
const targetSide =
diff --git a/src/services/export/mermaid/classDiagramMermaid.ts b/src/services/export/mermaid/classDiagramMermaid.ts
index 7a4e986a..801d3d1a 100644
--- a/src/services/export/mermaid/classDiagramMermaid.ts
+++ b/src/services/export/mermaid/classDiagramMermaid.ts
@@ -9,8 +9,24 @@ function sortNodesByPosition(nodes: FlowNode[]): FlowNode[] {
});
}
-function resolveClassRelation(edge: FlowEdge): { relation: string; label?: string } {
- const edgeData = edge.data as { classRelation?: string; classRelationLabel?: string } | undefined;
+interface ClassRelationEdgeData {
+ classRelation?: string;
+ classRelationLabel?: string;
+ classRelationSourceCardinality?: string;
+ classRelationTargetCardinality?: string;
+}
+
+function toMermaidClassIdentifier(value: string): string {
+ return value.trim().replace(/<([^<>]+)>/g, '~$1~');
+}
+
+function resolveClassRelation(edge: FlowEdge): {
+ relation: string;
+ label?: string;
+ sourceCardinality?: string;
+ targetCardinality?: string;
+} {
+ const edgeData = edge.data as ClassRelationEdgeData | undefined;
const dataRelation = edgeData?.classRelation?.trim();
const fallbackRelation =
typeof edge.label === 'string' && isClassRelationToken(edge.label.trim())
@@ -22,22 +38,26 @@ function resolveClassRelation(edge: FlowEdge): { relation: string; label?: strin
: (fallbackRelation ?? DEFAULT_CLASS_RELATION);
const dataLabel = edgeData?.classRelationLabel?.trim();
- if (dataLabel) return { relation, label: dataLabel };
+ const sourceCardinality = edgeData?.classRelationSourceCardinality?.trim();
+ const targetCardinality = edgeData?.classRelationTargetCardinality?.trim();
+ if (dataLabel) {
+ return { relation, label: dataLabel, sourceCardinality, targetCardinality };
+ }
if (typeof edge.label === 'string') {
const candidate = edge.label.trim();
if (candidate && candidate !== relation && !isClassRelationToken(candidate)) {
- return { relation, label: candidate };
+ return { relation, label: candidate, sourceCardinality, targetCardinality };
}
}
- return { relation };
+ return { relation, sourceCardinality, targetCardinality };
}
export function toClassDiagramMermaid(nodes: FlowNode[], edges: FlowEdge[]): string {
const lines: string[] = ['classDiagram'];
sortNodesByPosition(nodes).forEach((node) => {
- const id = node.id.trim();
+ const id = toMermaidClassIdentifier(node.id);
const stereotype =
typeof node.data.classStereotype === 'string' ? node.data.classStereotype.trim() : '';
const attributes = Array.isArray(node.data.classAttributes)
@@ -59,9 +79,13 @@ export function toClassDiagramMermaid(nodes: FlowNode[], edges: FlowEdge[]): str
});
edges.forEach((edge) => {
- const { relation, label } = resolveClassRelation(edge);
+ const { relation, label, sourceCardinality, targetCardinality } = resolveClassRelation(edge);
+ const sourceCardinalitySegment = sourceCardinality ? ` "${sourceCardinality}"` : '';
+ const targetCardinalitySegment = targetCardinality ? ` "${targetCardinality}"` : '';
const suffix = label ? ` : ${label}` : '';
- lines.push(` ${edge.source} ${relation} ${edge.target}${suffix}`);
+ lines.push(
+ ` ${toMermaidClassIdentifier(edge.source)}${sourceCardinalitySegment} ${relation}${targetCardinalitySegment} ${toMermaidClassIdentifier(edge.target)}${suffix}`
+ );
});
return `${lines.join('\n')}\n`;
diff --git a/src/services/export/mermaid/erDiagramMermaid.ts b/src/services/export/mermaid/erDiagramMermaid.ts
index d6d00469..55449edf 100644
--- a/src/services/export/mermaid/erDiagramMermaid.ts
+++ b/src/services/export/mermaid/erDiagramMermaid.ts
@@ -1,5 +1,5 @@
import type { FlowEdge, FlowNode } from '@/lib/types';
-import { normalizeErFields, stringifyErField } from '@/lib/entityFields';
+import { normalizeErFields, stringifyMermaidErField } from '@/lib/entityFields';
import { DEFAULT_ER_RELATION, isERRelationToken } from '@/lib/relationSemantics';
function sortNodesByPosition(nodes: FlowNode[]): FlowNode[] {
@@ -40,7 +40,7 @@ export function toERDiagramMermaid(nodes: FlowNode[], edges: FlowEdge[]): string
sortNodesByPosition(nodes).forEach((node) => {
lines.push(` ${node.id} {`);
const fields = normalizeErFields(node.data.erFields)
- .map((entry) => stringifyErField(entry).trim())
+ .map((entry) => stringifyMermaidErField(entry).trim())
.filter(Boolean);
fields.forEach((field) => lines.push(` ${field}`));
lines.push(' }');
diff --git a/src/services/export/mermaid/journeyMermaid.ts b/src/services/export/mermaid/journeyMermaid.ts
index 4af67c1a..5009517c 100644
--- a/src/services/export/mermaid/journeyMermaid.ts
+++ b/src/services/export/mermaid/journeyMermaid.ts
@@ -9,7 +9,14 @@ function sortNodesByPosition(nodes: FlowNode[]): FlowNode[] {
}
export function toJourneyMermaid(nodes: FlowNode[]): string {
- const lines: string[] = ['journey', ' title Journey'];
+ const titleNode = sortNodesByPosition(nodes).find((node) => {
+ return typeof node.data.journeyTitle === 'string' && node.data.journeyTitle.trim().length > 0;
+ });
+ const journeyTitle =
+ typeof titleNode?.data.journeyTitle === 'string' && titleNode.data.journeyTitle.trim()
+ ? titleNode.data.journeyTitle.trim()
+ : 'Journey';
+ const lines: string[] = ['journey', ` title ${journeyTitle}`];
const sectionMap = new Map();
sortNodesByPosition(nodes).forEach((node) => {
diff --git a/src/services/export/mermaid/mindmapMermaid.ts b/src/services/export/mermaid/mindmapMermaid.ts
index f2c75b0f..f5c6e211 100644
--- a/src/services/export/mermaid/mindmapMermaid.ts
+++ b/src/services/export/mermaid/mindmapMermaid.ts
@@ -14,6 +14,34 @@ function getIncomingTargets(edges: FlowEdge[]): Set {
return incoming;
}
+function wrapMindmapLabel(node: FlowNode, label: string): string {
+ const alias =
+ typeof node.data.mindmapAlias === 'string' && node.data.mindmapAlias.trim().length > 0
+ ? `${node.data.mindmapAlias.trim()}`
+ : '';
+
+ const withAlias = (content: string): string => (alias ? `${alias}${content}` : content);
+
+ switch (node.data.mindmapWrapper) {
+ case 'double-circle':
+ return withAlias(`((${label}))`);
+ case 'double-square':
+ return withAlias(`[[${label}]]`);
+ case 'stadium':
+ return withAlias(`([${label}])`);
+ case 'subroutine':
+ return withAlias(`[(${label})]`);
+ case 'square':
+ return withAlias(`[${label}]`);
+ case 'rounded':
+ return withAlias(`(${label})`);
+ case 'hexagon':
+ return withAlias(`{{${label}}}`);
+ default:
+ return alias ? `${alias} ${label}` : label;
+ }
+}
+
export function toMindmapMermaid(nodes: FlowNode[], edges: FlowEdge[]): string {
const lines: string[] = ['mindmap'];
const nodeById = new Map(nodes.map((node) => [node.id, node]));
@@ -51,7 +79,7 @@ export function toMindmapMermaid(nodes: FlowNode[], edges: FlowEdge[]): string {
? Math.max(0, Math.floor(node.data.mindmapDepth))
: null;
const effectiveDepth = explicitDepth ?? depth;
- lines.push(`${' '.repeat(effectiveDepth)}${label}`);
+ lines.push(`${' '.repeat(effectiveDepth)}${wrapMindmapLabel(node, label)}`);
const children = childrenById.get(node.id) ?? [];
children.forEach((childId) => {
diff --git a/src/services/export/mermaid/sequenceMermaid.ts b/src/services/export/mermaid/sequenceMermaid.ts
index db2eb679..671a4389 100644
--- a/src/services/export/mermaid/sequenceMermaid.ts
+++ b/src/services/export/mermaid/sequenceMermaid.ts
@@ -16,6 +16,61 @@ function resolveSequenceArrow(kind: string | undefined): string {
return '->>';
}
+function sortSequenceActivations(
+ activations: Array<{ order: number; activate: boolean }> | undefined
+): Array<{ order: number; activate: boolean }> {
+ return [...(activations ?? [])].sort((left, right) => left.order - right.order);
+}
+
+type SequenceFragmentState = {
+ type: string;
+ condition: string;
+ branchKind?: 'start' | 'else' | 'and' | 'option';
+};
+
+function syncFragmentState(
+ lines: string[],
+ currentFrag: SequenceFragmentState | null,
+ nextFrag: SequenceFragmentState | null
+): SequenceFragmentState | null {
+ if (
+ currentFrag &&
+ nextFrag &&
+ currentFrag.type === nextFrag.type &&
+ currentFrag.condition !== nextFrag.condition
+ ) {
+ if (nextFrag.type === 'alt' && nextFrag.branchKind === 'else') {
+ lines.push(` else ${nextFrag.condition}`);
+ return nextFrag;
+ }
+ if (nextFrag.type === 'par' && nextFrag.branchKind === 'and') {
+ lines.push(` and ${nextFrag.condition}`);
+ return nextFrag;
+ }
+ if (nextFrag.type === 'critical' && nextFrag.branchKind === 'option') {
+ lines.push(` option ${nextFrag.condition}`);
+ return nextFrag;
+ }
+ lines.push(' end');
+ currentFrag = null;
+ }
+
+ if (
+ currentFrag &&
+ (!nextFrag || nextFrag.type !== currentFrag.type || nextFrag.condition !== currentFrag.condition)
+ ) {
+ lines.push(' end');
+ currentFrag = null;
+ }
+
+ if (nextFrag && !currentFrag) {
+ lines.push(` ${nextFrag.type} ${nextFrag.condition}`);
+ return nextFrag;
+ }
+
+ return currentFrag;
+}
+
export function toSequenceMermaid(nodes: FlowNode[], edges: FlowEdge[]): string {
const lines: string[] = ['sequenceDiagram'];
const participantIdByNodeId = new Map();
@@ -44,42 +99,83 @@ export function toSequenceMermaid(nodes: FlowNode[], edges: FlowEdge[]): string
}
});
- participants.forEach((node) => {
+ const activations = participants.flatMap((node) => {
const id = participantIdByNodeId.get(node.id) ?? sanitizeId(node.id);
- const acts = node.data.seqActivations;
- if (acts && acts.length > 0) {
- acts.forEach((_, i) => {
- lines.push(` ${i % 2 === 0 ? 'activate' : 'deactivate'} ${id}`);
- });
- }
+ return sortSequenceActivations(node.data.seqActivations).map((activation) => ({
+ kind: 'activation' as const,
+ order: activation.order,
+ participantId: id,
+ activation,
+ }));
});
- notes.forEach((node) => {
- const text = String(node.data.label || '');
- const target = node.data.seqNoteTarget || '';
- const position = node.data.seqNotePosition || 'over';
- if (text && target) {
- lines.push(` note ${position} ${sanitizeId(target)}: ${sanitizeLabel(text)}`);
- }
+ const timelineEntries = [
+ ...notes.map((node) => ({
+ kind: 'note' as const,
+ order: typeof node.data.seqMessageOrder === 'number' ? node.data.seqMessageOrder : 0,
+ node,
+ })),
+ ...activations,
+ ...sortedEdges.map((edge) => ({
+ kind: 'edge' as const,
+ order: typeof edge.data?.seqMessageOrder === 'number' ? edge.data.seqMessageOrder : 0,
+ edge,
+ })),
+ ].sort((left, right) => {
+ if (left.order !== right.order) return left.order - right.order;
+ const timelinePriority = { note: 0, activation: 1, edge: 2 };
+ return timelinePriority[left.kind] - timelinePriority[right.kind];
});
- let currentFrag: { type: string; condition: string } | null = null;
- sortedEdges.forEach((edge) => {
- const frag = edge.data?.seqFragment;
+ let currentFrag: SequenceFragmentState | null = null;
+ timelineEntries.forEach((entry) => {
+ if (entry.kind === 'note') {
+ const node = entry.node;
+ const noteFrag = node.data.seqFragment
+ ? {
+ type: node.data.seqFragment.type,
+ condition: node.data.seqFragment.condition,
+ branchKind: node.data.seqFragment.branchKind,
+ }
+ : null;
+ currentFrag = syncFragmentState(lines, currentFrag, noteFrag);
+ const text = String(node.data.label || '');
+ const position = node.data.seqNotePosition || 'over';
+ const rawTargets = Array.isArray(node.data.seqNoteTargets)
+ ? node.data.seqNoteTargets
+ : typeof node.data.seqNoteTarget === 'string'
+ ? [node.data.seqNoteTarget]
+ : [];
+ const targets = rawTargets
+ .map((target) => (typeof target === 'string' ? target.trim() : ''))
+ .filter(Boolean)
+ .map((target) => sanitizeId(target));
- if (
- currentFrag &&
- (!frag || frag.type !== currentFrag.type || frag.condition !== currentFrag.condition)
- ) {
- lines.push(' end');
- currentFrag = null;
+ if (text && targets.length > 0) {
+ const targetExpr = position === 'over' && targets.length > 1
+ ? `${targets[0]}, ${targets[1]}`
+ : targets[0];
+ lines.push(` note ${position} ${targetExpr}: ${sanitizeLabel(text)}`);
+ }
+ return;
}
- if (frag && !currentFrag) {
- lines.push(` ${frag.type} ${frag.condition}`);
- currentFrag = { type: frag.type, condition: frag.condition };
+ if (entry.kind === 'activation') {
+ lines.push(` ${entry.activation.activate ? 'activate' : 'deactivate'} ${entry.participantId}`);
+ return;
}
+ const edge = entry.edge;
+ const frag = edge.data?.seqFragment
+ ? {
+ type: edge.data.seqFragment.type,
+ condition: edge.data.seqFragment.condition,
+ branchKind: edge.data.seqFragment.branchKind,
+ }
+ : null;
+
+ currentFrag = syncFragmentState(lines, currentFrag, frag);
+
const arrow = resolveSequenceArrow(edge.data?.seqMessageKind);
const label = typeof edge.label === 'string' ? edge.label.trim() : '';
const suffix = label ? `: ${label}` : '';
diff --git a/src/services/export/mermaid/stateDiagramMermaid.ts b/src/services/export/mermaid/stateDiagramMermaid.ts
index 3cc9e7e6..04d4b110 100644
--- a/src/services/export/mermaid/stateDiagramMermaid.ts
+++ b/src/services/export/mermaid/stateDiagramMermaid.ts
@@ -13,20 +13,26 @@ function escapeStateLabel(label: string): string {
return label.replace(/"/g, '\\"');
}
+function quoteIfNeeded(value: string): string {
+ return /[\s()[\]{}]/.test(value) ? `"${escapeStateLabel(value)}"` : value;
+}
+
function isStateDiagramNodeType(type: string | undefined): boolean {
- return type === 'state' || type === 'start' || type === 'process';
+ return (
+ type === 'state' ||
+ type === 'start' ||
+ type === 'process' ||
+ type === 'section' ||
+ type === 'annotation'
+ );
}
export function looksLikeStateDiagram(nodes: FlowNode[]): boolean {
if (nodes.length === 0) return false;
const hasStateStartNode = nodes.some((node) => node.id.startsWith('state_start_'));
const hasExplicitStateNode = nodes.some((node) => node.type === 'state');
- const hasCompositeParenting = nodes.some((node) => {
- const parentId = getNodeParentId(node);
- return parentId.length > 0;
- });
- if (!hasStateStartNode && !hasExplicitStateNode && !hasCompositeParenting) {
+ if (!hasStateStartNode && !hasExplicitStateNode) {
return false;
}
@@ -73,9 +79,33 @@ function toStateNodeToken(nodeId: string, startNodeIds: Set): string {
return nodeId;
}
-export function toStateDiagramMermaid(nodes: FlowNode[], edges: FlowEdge[]): string {
+function resolveStateDiagramDirection(
+ nodes: FlowNode[],
+ direction?: string
+): 'TB' | 'LR' {
+ if (direction === 'LR' || direction === 'TB') {
+ return direction;
+ }
+
+ const xValues = nodes.map((node) => node.position.x);
+ const yValues = nodes.map((node) => node.position.y);
+ const xSpan = Math.max(...xValues) - Math.min(...xValues);
+ const ySpan = Math.max(...yValues) - Math.min(...yValues);
+ return xSpan > ySpan * 1.2 ? 'LR' : 'TB';
+}
+
+export function toStateDiagramMermaid(
+ nodes: FlowNode[],
+ edges: FlowEdge[],
+ direction?: string
+): string {
const lines: string[] = ['stateDiagram-v2'];
const nodeById = new Map(nodes.map((node) => [node.id, node]));
+ const noteNodes = sortNodesByPosition(
+ nodes.filter(
+ (node) => node.type === 'annotation' && typeof node.data.stateNoteTarget === 'string'
+ )
+ );
const childrenByParentId = new Map();
const topLevelNodes: FlowNode[] = [];
@@ -90,11 +120,7 @@ export function toStateDiagramMermaid(nodes: FlowNode[], edges: FlowEdge[]): str
childrenByParentId.set(parentId, parentChildren);
});
- const xValues = nodes.map((node) => node.position.x);
- const yValues = nodes.map((node) => node.position.y);
- const xSpan = Math.max(...xValues) - Math.min(...xValues);
- const ySpan = Math.max(...yValues) - Math.min(...yValues);
- lines.push(` direction ${xSpan > ySpan * 1.2 ? 'LR' : 'TB'}`);
+ lines.push(` direction ${resolveStateDiagramDirection(nodes, direction)}`);
function emitNodeDeclaration(node: FlowNode, depth: number): void {
const indent = ' '.repeat(depth);
@@ -105,12 +131,20 @@ export function toStateDiagramMermaid(nodes: FlowNode[], edges: FlowEdge[]): str
}
const label = String(node.data.label || node.id).trim() || node.id;
+ if (node.data.stateControlKind === 'fork' || node.data.stateControlKind === 'join') {
+ lines.push(`${indent}state ${node.id} <<${node.data.stateControlKind}>>`);
+ return;
+ }
if (children.length === 0) {
lines.push(`${indent}state "${escapeStateLabel(label)}" as ${node.id}`);
return;
}
- lines.push(`${indent}state ${node.id} {`);
+ if (label !== node.id) {
+ lines.push(`${indent}state "${escapeStateLabel(label)}" as ${node.id} {`);
+ } else {
+ lines.push(`${indent}state ${node.id} {`);
+ }
children.forEach((childNode) => emitNodeDeclaration(childNode, depth + 1));
edges.forEach((edge) => {
const sourceNode = nodeById.get(edge.source);
@@ -120,7 +154,8 @@ export function toStateDiagramMermaid(nodes: FlowNode[], edges: FlowEdge[]): str
const sourceParentId = sourceNode ? getNodeParentId(sourceNode) : '';
const targetParentId = targetNode ? getNodeParentId(targetNode) : '';
const shouldEmitInsideParent =
- (sourceParentId === node.id && (targetParentId === node.id || edge.target.startsWith('state_start_'))) ||
+ (sourceParentId === node.id &&
+ (targetParentId === node.id || edge.target.startsWith('state_start_'))) ||
(targetParentId === node.id && edge.source.startsWith('state_start_'));
if (!shouldEmitInsideParent) {
@@ -146,7 +181,10 @@ export function toStateDiagramMermaid(nodes: FlowNode[], edges: FlowEdge[]): str
const targetNode = nodeById.get(edge.target);
if (!sourceNode || !targetNode) return;
- if (getNodeParentId(sourceNode) && getNodeParentId(sourceNode) === getNodeParentId(targetNode)) {
+ if (
+ getNodeParentId(sourceNode) &&
+ getNodeParentId(sourceNode) === getNodeParentId(targetNode)
+ ) {
return;
}
@@ -158,5 +196,14 @@ export function toStateDiagramMermaid(nodes: FlowNode[], edges: FlowEdge[]): str
lines.push(` ${sourceToken} ${connector} ${targetToken}${suffix}`);
});
+ noteNodes.forEach((node) => {
+ const position = String(node.data.stateNotePosition || 'right').trim();
+ const target = String(node.data.stateNoteTarget || '').trim();
+ const label = String(node.data.label || '').trim();
+ if (!target || !label) return;
+
+ lines.push(` note ${position} of ${quoteIfNeeded(target)}: ${escapeStateLabel(label)}`);
+ });
+
return `${lines.join('\n')}\n`;
}
diff --git a/src/services/export/mermaidBuilder.ts b/src/services/export/mermaidBuilder.ts
index 300a5519..e78a6380 100644
--- a/src/services/export/mermaidBuilder.ts
+++ b/src/services/export/mermaidBuilder.ts
@@ -1,5 +1,5 @@
import type { FlowEdge, FlowNode } from '@/lib/types';
-import { sanitizeId, sanitizeLabel } from './formatting';
+import { sanitizeId, sanitizeLabel, sanitizeEdgeLabel } from './formatting';
import { toArchitectureMermaid } from './mermaid/architectureMermaid';
import { toMindmapMermaid } from './mermaid/mindmapMermaid';
import { toJourneyMermaid } from './mermaid/journeyMermaid';
@@ -48,63 +48,203 @@ function resolveFlowchartConnector(edge: FlowEdge): string {
return body;
}
-function toFlowchartMermaid(nodes: FlowNode[], edges: FlowEdge[]): string {
- let mermaid = 'flowchart TD\n';
+function resolveShapeBrackets(
+ shape: string | undefined,
+ type: string | undefined
+): { start: string; end: string } {
+ switch (shape) {
+ case 'diamond':
+ return { start: '{', end: '}' };
+ case 'hexagon':
+ return { start: '{{', end: '}}' };
+ case 'cylinder':
+ return { start: '[(', end: ')]' };
+ case 'circle':
+ return { start: '((', end: '))' };
+ case 'ellipse':
+ return { start: '([', end: '])' };
+ case 'capsule':
+ return { start: '([', end: '])' };
+ case 'parallelogram':
+ return { start: '>', end: ']' };
+ case 'rounded':
+ return { start: '(', end: ')' };
+ default:
+ break;
+ }
- nodes.forEach((node) => {
- const label = sanitizeLabel(node.data.label);
- const id = sanitizeId(node.id);
- let shapeStart = '[';
- let shapeEnd = ']';
-
- const shape = node.data.shape || 'rounded';
- const type = node.type;
-
- if (shape === 'diamond') {
- shapeStart = '{';
- shapeEnd = '}';
- } else if (shape === 'hexagon') {
- shapeStart = '{{';
- shapeEnd = '}}';
- } else if (shape === 'cylinder') {
- shapeStart = '[(';
- shapeEnd = ')]';
- } else if (shape === 'ellipse') {
- shapeStart = '([';
- shapeEnd = '])';
- } else if (shape === 'circle') {
- shapeStart = '((';
- shapeEnd = '))';
- } else if (shape === 'parallelogram') {
- shapeStart = '>';
- shapeEnd = ']';
- } else if (type === 'decision') {
- shapeStart = '{';
- shapeEnd = '}';
- } else if (type === 'start' || type === 'end') {
- shapeStart = '([';
- shapeEnd = '])';
+ if (type === 'decision') return { start: '{', end: '}' };
+ if (type === 'start' || type === 'end') return { start: '([', end: '])' };
+
+ return { start: '[', end: ']' };
+}
+
+function collectSectionTree(nodes: FlowNode[]): {
+ roots: FlowNode[];
+ childrenByParent: Map;
+} {
+ const childrenByParent = new Map();
+ const roots: FlowNode[] = [];
+
+ for (const node of nodes) {
+ const parentId = node.parentId;
+ if (parentId) {
+ const children = childrenByParent.get(parentId) ?? [];
+ children.push(node);
+ childrenByParent.set(parentId, children);
+ } else if (node.type !== 'section' && node.type !== 'group') {
+ roots.push(node);
}
+ }
- mermaid += ` ${id}${shapeStart}"${label}"${shapeEnd}\n`;
- });
+ return { roots, childrenByParent };
+}
+
+function emitFlowchartNode(node: FlowNode, indent: string): string {
+ const label = sanitizeLabel(node.data.label);
+ const id = sanitizeId(node.id);
+ const { start, end } = resolveShapeBrackets(node.data.shape, node.type);
+ return `${indent}${id}${start}"${label}"${end}\n`;
+}
+
+function emitFlowchartNodeStyle(node: FlowNode, indent: string): string | null {
+ if (node.type === 'section' || node.type === 'group') {
+ return null;
+ }
+
+ const styleParts: string[] = [];
+ const backgroundColor =
+ typeof node.style?.backgroundColor === 'string' ? node.style.backgroundColor : undefined;
+ const borderColor = typeof node.style?.borderColor === 'string' ? node.style.borderColor : undefined;
+ const textColor = typeof node.style?.color === 'string' ? node.style.color : undefined;
+
+ if (backgroundColor) {
+ styleParts.push(`fill:${backgroundColor}`);
+ }
+ if (borderColor) {
+ styleParts.push(`stroke:${borderColor}`);
+ }
+ if (textColor) {
+ styleParts.push(`color:${textColor}`);
+ }
+
+ if (styleParts.length === 0) {
+ return null;
+ }
+
+ return `${indent}style ${sanitizeId(node.id)} ${styleParts.join(',')}\n`;
+}
+
+function emitFlowchartLinkStyle(edge: FlowEdge, index: number, indent: string): string | null {
+ const styleParts: string[] = [];
+ const stroke = typeof edge.style?.stroke === 'string' ? edge.style.stroke : undefined;
+ const strokeWidth = edge.style?.strokeWidth;
+ const normalizedStrokeWidth =
+ typeof strokeWidth === 'number'
+ ? strokeWidth
+ : typeof strokeWidth === 'string'
+ ? Number(strokeWidth)
+ : undefined;
+
+ if (stroke) {
+ styleParts.push(`stroke:${stroke}`);
+ }
+ if (typeof normalizedStrokeWidth === 'number' && Number.isFinite(normalizedStrokeWidth)) {
+ styleParts.push(`stroke-width:${normalizedStrokeWidth}px`);
+ }
+
+ if (styleParts.length === 0) {
+ return null;
+ }
+
+ return `${indent}linkStyle ${index} ${styleParts.join(',')}\n`;
+}
+
+function emitSectionBlock(
+ section: FlowNode,
+ children: FlowNode[],
+ childrenByParent: Map,
+ indent: string
+): string {
+ const label = sanitizeLabel(section.data.label);
+ const id = sanitizeId(section.id);
+ const shouldEmitExplicitId = label !== id && !id.startsWith('subgraph_');
+ const subgraphHeader = shouldEmitExplicitId
+ ? `${id}[${JSON.stringify(label)}]`
+ : /[\s()[\]{}]/.test(label)
+ ? `"${label}"`
+ : label;
+ let out = `${indent}subgraph ${subgraphHeader}\n`;
+
+ for (const child of children) {
+ if (child.type === 'section' || child.type === 'group') {
+ const grandChildren = childrenByParent.get(child.id) ?? [];
+ out += emitSectionBlock(child, grandChildren, childrenByParent, indent + ' ');
+ } else {
+ out += emitFlowchartNode(child, indent + ' ');
+ }
+ }
+
+ out += `${indent}end\n`;
+ return out;
+}
+
+function toFlowchartMermaid(nodes: FlowNode[], edges: FlowEdge[], direction?: string): string {
+ const dir = direction ?? 'TD';
+ let mermaid = `flowchart ${dir}\n`;
+
+ const sectionNodes = nodes.filter((n) => n.type === 'section' || n.type === 'group');
+ const hasSubgraphs = sectionNodes.length > 0;
+
+ if (hasSubgraphs) {
+ const { roots, childrenByParent } = collectSectionTree(nodes);
+
+ for (const section of sectionNodes) {
+ if (!section.parentId) {
+ const children = childrenByParent.get(section.id) ?? [];
+ mermaid += emitSectionBlock(section, children, childrenByParent, ' ');
+ }
+ }
+
+ for (const node of roots) {
+ mermaid += emitFlowchartNode(node, ' ');
+ }
+ } else {
+ for (const node of nodes) {
+ mermaid += emitFlowchartNode(node, ' ');
+ }
+ }
edges.forEach((edge) => {
const source = sanitizeId(edge.source);
const target = sanitizeId(edge.target);
const connector = resolveFlowchartConnector(edge);
if (edge.label) {
- const label = sanitizeLabel(edge.label as string);
+ const label = sanitizeEdgeLabel(edge.label as string);
mermaid += ` ${source} ${connector}|"${label}"| ${target}\n`;
} else {
mermaid += ` ${source} ${connector} ${target}\n`;
}
});
+ nodes.forEach((node) => {
+ const styleDirective = emitFlowchartNodeStyle(node, ' ');
+ if (styleDirective) {
+ mermaid += styleDirective;
+ }
+ });
+
+ edges.forEach((edge, index) => {
+ const linkStyleDirective = emitFlowchartLinkStyle(edge, index, ' ');
+ if (linkStyleDirective) {
+ mermaid += linkStyleDirective;
+ }
+ });
+
return mermaid;
}
-export function toMermaid(nodes: FlowNode[], edges: FlowEdge[]): string {
+export function toMermaid(nodes: FlowNode[], edges: FlowEdge[], direction?: string): string {
const architectureNodeCount = nodes.filter((node) => node.type === 'architecture').length;
if (nodes.length > 0 && architectureNodeCount === nodes.length) {
return toArchitectureMermaid(nodes, edges);
@@ -131,15 +271,18 @@ export function toMermaid(nodes: FlowNode[], edges: FlowEdge[]): string {
}
const seqNodeCount = nodes.filter(
- (node) => node.type === 'sequence_participant' || node.type === 'sequence_note'
+ (node) =>
+ node.type === 'sequence_participant'
+ || node.type === 'sequence_note'
+ || Boolean(node.data.seqFragmentId)
).length;
if (nodes.length > 0 && seqNodeCount === nodes.length) {
return toSequenceMermaid(nodes, edges);
}
if (looksLikeStateDiagram(nodes)) {
- return toStateDiagramMermaid(nodes, edges);
+ return toStateDiagramMermaid(nodes, edges, direction);
}
- return toFlowchartMermaid(nodes, edges);
+ return toFlowchartMermaid(nodes, edges, direction);
}
diff --git a/src/services/export/mermaidExportQuality.test.ts b/src/services/export/mermaidExportQuality.test.ts
new file mode 100644
index 00000000..bde86d8f
--- /dev/null
+++ b/src/services/export/mermaidExportQuality.test.ts
@@ -0,0 +1,193 @@
+import { describe, expect, it } from 'vitest';
+import { parseMermaidByType } from '@/services/mermaid/parseMermaidByType';
+import { enrichNodesWithIcons } from '@/lib/nodeEnricher';
+import { toMermaid } from '@/services/export/mermaidBuilder';
+import type { FlowNode, FlowEdge } from '@/lib/types';
+
+describe('Mermaid Export Quality', () => {
+ it('exports rounded shape as (label) not [label]', async () => {
+ const input = `flowchart TD
+ A("Rounded Node")
+
+ A --> B["Rectangle Node"]`;
+
+ const parsed = parseMermaidByType(input);
+ const enriched = await enrichNodesWithIcons(parsed.nodes);
+ const exported = toMermaid(enriched, parsed.edges);
+
+ expect(exported).toContain('("Rounded Node")');
+ });
+
+ it('exports start/end as stadium ([label])', async () => {
+ const input = `flowchart TD
+ S(["Start"])
+ E(("End"))
+
+ S --> E`;
+
+ const parsed = parseMermaidByType(input);
+ const enriched = await enrichNodesWithIcons(parsed.nodes);
+ const exported = toMermaid(enriched, parsed.edges);
+
+ expect(exported).toContain('(["Start"])');
+ expect(exported).toContain('(("End"))');
+ });
+
+ it('exports subgraph blocks', async () => {
+ const input = `flowchart TD
+ subgraph Frontend
+ UI["React App"]
+ end
+ subgraph Backend
+ API["Express API"]
+ DB[("PostgreSQL")]
+ end
+ UI --> API
+ API --> DB`;
+
+ const parsed = parseMermaidByType(input);
+ const enriched = await enrichNodesWithIcons(parsed.nodes);
+ const exported = toMermaid(enriched, parsed.edges);
+
+ expect(exported).toContain('subgraph Frontend');
+ expect(exported).toContain('subgraph Backend');
+ expect(exported).toContain('React App');
+ expect(exported).toContain('Express API');
+ expect(exported).toContain('end');
+ });
+
+ it('preserves direction when passed', async () => {
+ const input = `flowchart LR
+ A["Left"] --> B["Right"]`;
+
+ const parsed = parseMermaidByType(input);
+ const enriched = await enrichNodesWithIcons(parsed.nodes);
+ const exported = toMermaid(enriched, parsed.edges, 'LR');
+
+ expect(exported).toContain('flowchart LR');
+ });
+
+ it('defaults to TD when no direction specified', async () => {
+ const input = `flowchart TD
+ A["A"] --> B["B"]`;
+
+ const parsed = parseMermaidByType(input);
+ const enriched = await enrichNodesWithIcons(parsed.nodes);
+ const exported = toMermaid(enriched, parsed.edges);
+
+ expect(exported).toContain('flowchart TD');
+ });
+
+ it('exports all shape types correctly', async () => {
+ const nodes = [
+ {
+ id: 'a',
+ type: 'process',
+ position: { x: 0, y: 0 },
+ data: { label: 'Rounded', shape: 'rounded', color: 'slate' },
+ },
+ {
+ id: 'b',
+ type: 'process',
+ position: { x: 0, y: 0 },
+ data: { label: 'Rect', shape: undefined, color: 'slate' },
+ },
+ {
+ id: 'c',
+ type: 'process',
+ position: { x: 0, y: 0 },
+ data: { label: 'Diamond', shape: 'diamond', color: 'slate' },
+ },
+ {
+ id: 'd',
+ type: 'process',
+ position: { x: 0, y: 0 },
+ data: { label: 'Cylinder', shape: 'cylinder', color: 'slate' },
+ },
+ {
+ id: 'e',
+ type: 'process',
+ position: { x: 0, y: 0 },
+ data: { label: 'Circle', shape: 'circle', color: 'slate' },
+ },
+ {
+ id: 'f',
+ type: 'process',
+ position: { x: 0, y: 0 },
+ data: { label: 'Capsule', shape: 'capsule', color: 'slate' },
+ },
+ {
+ id: 'g',
+ type: 'process',
+ position: { x: 0, y: 0 },
+ data: { label: 'Hexagon', shape: 'hexagon', color: 'slate' },
+ },
+ ];
+
+ const exported = toMermaid(nodes as unknown as FlowNode[], [] as unknown as FlowEdge[]);
+
+ expect(exported).toContain('("Rounded")');
+ expect(exported).toContain('["Rect"]');
+ expect(exported).toContain('{"Diamond"}');
+ expect(exported).toContain('[("Cylinder")]');
+ expect(exported).toContain('(("Circle"))');
+ expect(exported).toContain('(["Capsule"])');
+ expect(exported).toContain('{{"Hexagon"}}');
+ });
+
+ it('roundtrips basic flowchart with shapes', async () => {
+ const input = `flowchart TD
+ S(["Start"])
+ P["Process"]
+ D{"Decision"}
+ E(("End"))
+
+ S --> P
+ P --> D
+ D -->|"Yes"| E`;
+
+ const parsed = parseMermaidByType(input);
+ const enriched = await enrichNodesWithIcons(parsed.nodes);
+ const exported = toMermaid(enriched, parsed.edges);
+
+ expect(exported).toContain('flowchart');
+ expect(exported).toContain('Start');
+ expect(exported).toContain('Process');
+ expect(exported).toContain('Decision');
+ expect(exported).toContain('End');
+ expect(exported).toContain('Yes');
+ });
+
+ it('preserves parens and apostrophes in labels', async () => {
+ const input = `flowchart TD
+ A["Parse (tokens)"] --> B["O'Brien"]
+ B --> C["Say \\"hello\\""]`;
+
+ const parsed = parseMermaidByType(input);
+ const enriched = await enrichNodesWithIcons(parsed.nodes);
+ const exported = toMermaid(enriched, parsed.edges);
+
+ expect(exported).toContain('Parse (tokens)');
+ expect(exported).toContain("O'Brien");
+ });
+
+ it('handles empty diagram', () => {
+ const exported = toMermaid([], []);
+ expect(exported).toContain('flowchart');
+ });
+
+ it('exports imported flowchart styling semantics as style and linkStyle directives', async () => {
+ const input = `flowchart TD
+ A["API"] --> B[("DB")]
+ classDef hot fill:#dff,stroke:#08c,color:#024
+ class A hot
+ linkStyle 0 stroke:#f66,stroke-width:3px`;
+
+ const parsed = parseMermaidByType(input);
+ const enriched = await enrichNodesWithIcons(parsed.nodes);
+ const exported = toMermaid(enriched, parsed.edges);
+
+ expect(exported).toContain('style A fill:#dff,stroke:#08c,color:#024');
+ expect(exported).toContain('linkStyle 0 stroke:#f66,stroke-width:3px');
+ });
+});
diff --git a/src/services/export/plantumlBuilder.ts b/src/services/export/plantumlBuilder.ts
index 9e636ec4..cb5b33b0 100644
--- a/src/services/export/plantumlBuilder.ts
+++ b/src/services/export/plantumlBuilder.ts
@@ -1,5 +1,5 @@
import type { FlowEdge, FlowNode } from '@/lib/types';
-import { sanitizeId, sanitizeLabel } from './formatting';
+import { sanitizeId, sanitizeLabel, sanitizeEdgeLabel } from './formatting';
export function toPlantUML(nodes: FlowNode[], edges: FlowEdge[]): string {
let plantuml = '@startuml\n\n';
@@ -28,7 +28,7 @@ export function toPlantUML(nodes: FlowNode[], edges: FlowEdge[]): string {
edges.forEach((edge) => {
const source = sanitizeId(edge.source);
const target = sanitizeId(edge.target);
- const label = edge.label ? ` : ${sanitizeLabel(edge.label as string)}` : '';
+ const label = edge.label ? ` : ${sanitizeEdgeLabel(edge.label as string)}` : '';
plantuml += `${source} --> ${target}${label}\n`;
});
diff --git a/src/services/exportService.test.ts b/src/services/exportService.test.ts
index 65cabdc8..33cd509d 100644
--- a/src/services/exportService.test.ts
+++ b/src/services/exportService.test.ts
@@ -41,6 +41,34 @@ describe('toMermaid', () => {
expect(output).toContain('api:R <--> L:db : HTTPS:443');
});
+ it('preserves nested architecture groups during export', () => {
+ const nodes: FlowNode[] = [
+ {
+ id: 'global',
+ type: 'architecture',
+ position: { x: 0, y: 0 },
+ data: { label: 'Global', archResourceType: 'group' },
+ },
+ {
+ id: 'prod',
+ type: 'architecture',
+ position: { x: 240, y: 0 },
+ data: { label: 'Prod', archResourceType: 'group', archProvider: 'cloud', archBoundaryId: 'global' },
+ },
+ {
+ id: 'api',
+ type: 'architecture',
+ position: { x: 480, y: 0 },
+ data: { label: 'API', archResourceType: 'service', archBoundaryId: 'prod' },
+ },
+ ];
+
+ const output = toMermaid(nodes, []);
+ expect(output).toContain('group global[Global]');
+ expect(output).toContain('group prod(cloud)[Prod] in global');
+ expect(output).toContain('service api[API] in prod');
+ });
+
it('keeps flowchart export path for mixed or non-architecture nodes', () => {
const nodes: FlowNode[] = [
{ id: 'a', type: 'process', position: { x: 0, y: 0 }, data: { label: 'A' } },
@@ -96,6 +124,29 @@ describe('toMermaid', () => {
expect(output).toContain('api:L --> R:db : HTTPS:443');
});
+ it('exports richer architecture node kinds without collapsing them to service', () => {
+ const nodes: FlowNode[] = [
+ { id: 'user', type: 'architecture', position: { x: 0, y: 0 }, data: { label: 'User', archResourceType: 'person' } },
+ {
+ id: 'app',
+ type: 'architecture',
+ position: { x: 200, y: 0 },
+ data: { label: 'App', archResourceType: 'container', archProvider: 'server' },
+ },
+ {
+ id: 'data',
+ type: 'architecture',
+ position: { x: 400, y: 0 },
+ data: { label: 'Data Store', archResourceType: 'database_container', archProvider: 'database' },
+ },
+ ];
+
+ const output = toMermaid(nodes, []);
+ expect(output).toContain('person user[User]');
+ expect(output).toContain('container app(server)[App]');
+ expect(output).toContain('database_container data(database)[Data Store]');
+ });
+
it('exports dashed flowchart edges with dotted mermaid connector', () => {
const nodes: FlowNode[] = [
{ id: 'a', type: 'process', position: { x: 0, y: 0 }, data: { label: 'A' } },
@@ -150,6 +201,46 @@ describe('toMermaid', () => {
expect(output).toContain('a <-- b');
});
+ it('exports flowchart node styles as Mermaid style directives', () => {
+ const nodes: FlowNode[] = [
+ {
+ id: 'a',
+ type: 'process',
+ position: { x: 0, y: 0 },
+ data: { label: 'API' },
+ style: { backgroundColor: '#dff', borderColor: '#08c', color: '#024' },
+ },
+ {
+ id: 'b',
+ type: 'process',
+ position: { x: 200, y: 0 },
+ data: { label: 'DB' },
+ },
+ ];
+ const edges: FlowEdge[] = [{ id: 'e1', source: 'a', target: 'b' }];
+
+ const output = toMermaid(nodes, edges);
+ expect(output).toContain('style a fill:#dff,stroke:#08c,color:#024');
+ });
+
+ it('exports flowchart edge styles as Mermaid linkStyle directives', () => {
+ const nodes: FlowNode[] = [
+ { id: 'a', type: 'process', position: { x: 0, y: 0 }, data: { label: 'A' } },
+ { id: 'b', type: 'process', position: { x: 200, y: 0 }, data: { label: 'B' } },
+ ];
+ const edges: FlowEdge[] = [
+ {
+ id: 'e1',
+ source: 'a',
+ target: 'b',
+ style: { stroke: '#f66', strokeWidth: 3 },
+ },
+ ];
+
+ const output = toMermaid(nodes, edges);
+ expect(output).toContain('linkStyle 0 stroke:#f66,stroke-width:3px');
+ });
+
it('exports stateDiagram-only graphs through stateDiagram-v2 path', () => {
const nodes: FlowNode[] = [
{ id: 'state_start_0', type: 'start', position: { x: 0, y: 0 }, data: { label: '' } },
diff --git a/src/services/exportService.ts b/src/services/exportService.ts
index cd261588..c80a023b 100644
--- a/src/services/exportService.ts
+++ b/src/services/exportService.ts
@@ -2,8 +2,8 @@ import type { FlowEdge, FlowNode } from '@/lib/types';
import { toMermaid as toMermaidBuilder } from './export/mermaidBuilder';
import { toPlantUML as toPlantUMLBuilder } from './export/plantumlBuilder';
-export function toMermaid(nodes: FlowNode[], edges: FlowEdge[]): string {
- return toMermaidBuilder(nodes, edges);
+export function toMermaid(nodes: FlowNode[], edges: FlowEdge[], direction?: string): string {
+ return toMermaidBuilder(nodes, edges, direction);
}
export function toPlantUML(nodes: FlowNode[], edges: FlowEdge[]): string {
diff --git a/src/services/flowchartRoundTrip.test.ts b/src/services/flowchartRoundTrip.test.ts
index d0b6fe80..7ce7d474 100644
--- a/src/services/flowchartRoundTrip.test.ts
+++ b/src/services/flowchartRoundTrip.test.ts
@@ -22,8 +22,8 @@ describe('flowchart round-trip', () => {
expect(first.edges[2].markerStart).toBeDefined();
expect(first.edges[2].markerEnd).toBeUndefined();
- const exported = toMermaid(first.nodes, first.edges);
- expect(exported.startsWith('flowchart TD')).toBe(true);
+ const exported = toMermaid(first.nodes, first.edges, first.direction);
+ expect(exported.startsWith('flowchart TB')).toBe(true);
expect(exported).toContain('A -.->|"warmup"| B');
expect(exported).toContain('B ==> C');
expect(exported).toContain('C <-- D');
@@ -62,4 +62,106 @@ describe('flowchart round-trip', () => {
expect(second.edges[0].markerStart).toBeDefined();
expect(second.edges[0].markerEnd).toBeDefined();
});
+
+ it('preserves direction through parse/export/parse', () => {
+ const source = `
+ flowchart LR
+ A["Left"] --> B["Right"]
+ `;
+
+ const first = parseMermaidByType(source);
+ expect(first.direction).toBe('LR');
+
+ const exported = toMermaid(first.nodes, first.edges, first.direction);
+ expect(exported).toContain('flowchart LR');
+
+ const second = parseMermaidByType(exported);
+ expect(second.direction).toBe('LR');
+ });
+
+ it('preserves explicit subgraph ids through parse/export/parse', () => {
+ const source = `
+ flowchart TD
+ subgraph api[API Layer]
+ A[Gateway] --> B[Service]
+ end
+ `;
+
+ const first = parseMermaidByType(source);
+ expect(first.error).toBeUndefined();
+ const section = first.nodes.find((node) => node.type === 'section');
+ expect(section?.id).toBe('api');
+ expect(section?.data.label).toBe('API Layer');
+
+ const exported = toMermaid(first.nodes, first.edges, first.direction);
+ expect(exported).toContain('subgraph api["API Layer"]');
+
+ const second = parseMermaidByType(exported);
+ expect(second.error).toBeUndefined();
+ expect(second.nodes.find((node) => node.type === 'section')?.id).toBe('api');
+ expect(second.nodes.find((node) => node.type === 'section')?.data.label).toBe('API Layer');
+ });
+
+ it('preserves dotted flowchart ids and modern annotation labels through parse/export/parse', () => {
+ const source = `
+ flowchart TD
+ api.gateway@{ shape: rect, label: "API Gateway" } --> db.primary@{ shape: cyl, label: "Primary DB" }
+ `;
+
+ const first = parseMermaidByType(source);
+ expect(first.error).toBeUndefined();
+ expect(first.nodes.find((node) => node.id === 'api.gateway')?.data.label).toBe('API Gateway');
+ expect(first.nodes.find((node) => node.id === 'db.primary')?.data.label).toBe('Primary DB');
+
+ const exported = toMermaid(first.nodes, first.edges, first.direction);
+ expect(exported).toContain('api.gateway("API Gateway")');
+ expect(exported).toContain('db.primary[("Primary DB")]');
+
+ const second = parseMermaidByType(exported);
+ expect(second.error).toBeUndefined();
+ expect(second.nodes.find((node) => node.id === 'api.gateway')?.data.label).toBe('API Gateway');
+ expect(second.nodes.find((node) => node.id === 'db.primary')?.data.label).toBe('Primary DB');
+ expect(second.edges[0]).toMatchObject({
+ source: 'api.gateway',
+ target: 'db.primary',
+ });
+ });
+
+ it('preserves Mermaid-imported node and edge styling through parse/export/parse', () => {
+ const source = `
+ flowchart TD
+ A[API] --> B[(DB)]
+ classDef hot fill:#dff,stroke:#08c,color:#024
+ class A hot
+ linkStyle 0 stroke:#f66,stroke-width:3px
+ `;
+
+ const first = parseMermaidByType(source);
+ expect(first.error).toBeUndefined();
+ expect(first.nodes.find((node) => node.id === 'A')?.style).toMatchObject({
+ backgroundColor: '#dff',
+ borderColor: '#08c',
+ color: '#024',
+ });
+ expect(first.edges[0].style).toMatchObject({
+ stroke: '#f66',
+ strokeWidth: 3,
+ });
+
+ const exported = toMermaid(first.nodes, first.edges, first.direction);
+ expect(exported).toContain('style A fill:#dff,stroke:#08c,color:#024');
+ expect(exported).toContain('linkStyle 0 stroke:#f66,stroke-width:3px');
+
+ const second = parseMermaidByType(exported);
+ expect(second.error).toBeUndefined();
+ expect(second.nodes.find((node) => node.id === 'A')?.style).toMatchObject({
+ backgroundColor: '#dff',
+ borderColor: '#08c',
+ color: '#024',
+ });
+ expect(second.edges[0].style).toMatchObject({
+ stroke: '#f66',
+ strokeWidth: 3,
+ });
+ });
});
diff --git a/src/services/flowpilot/assetGrounding.ts b/src/services/flowpilot/assetGrounding.ts
index 98877e7f..5f1d0f5e 100644
--- a/src/services/flowpilot/assetGrounding.ts
+++ b/src/services/flowpilot/assetGrounding.ts
@@ -12,25 +12,155 @@ const ALL_GROUNDING_CATEGORIES: DomainLibraryCategory[] = [
];
const SERVICE_ALIASES: Array<{ query: string; categories?: DomainLibraryCategory[] }> = [
+ // AWS Services
{ query: 'API Gateway', categories: ['aws'] },
{ query: 'Lambda', categories: ['aws'] },
{ query: 'S3', categories: ['aws'] },
{ query: 'RDS', categories: ['aws'] },
{ query: 'ElastiCache', categories: ['aws'] },
{ query: 'Cognito', categories: ['aws'] },
+ { query: 'DynamoDB', categories: ['aws'] },
+ { query: 'Aurora', categories: ['aws'] },
+ { query: 'EC2', categories: ['aws'] },
+ { query: 'ECS', categories: ['aws'] },
+ { query: 'EKS', categories: ['aws'] },
+ { query: 'SQS', categories: ['aws'] },
+ { query: 'SNS', categories: ['aws'] },
+ { query: 'CloudFront', categories: ['aws'] },
+ { query: 'ALB', categories: ['aws'] },
+ { query: 'EventBridge', categories: ['aws'] },
+ { query: 'Step Functions', categories: ['aws'] },
+ { query: 'CloudWatch', categories: ['aws'] },
+ { query: 'Secrets Manager', categories: ['aws'] },
+ { query: 'Kinesis', categories: ['aws'] },
+ { query: 'Redshift', categories: ['aws'] },
+ { query: 'Glue', categories: ['aws'] },
+ { query: 'SageMaker', categories: ['aws'] },
+
+ // Azure Services
{ query: 'Azure Functions', categories: ['azure'] },
{ query: 'Azure SQL', categories: ['azure'] },
{ query: 'Storage Account', categories: ['azure'] },
{ query: 'API Management', categories: ['azure'] },
+ { query: 'Service Bus', categories: ['azure'] },
+ { query: 'Event Hubs', categories: ['azure'] },
+ { query: 'Cosmos DB', categories: ['azure'] },
+ { query: 'Front Door', categories: ['azure'] },
+ { query: 'Key Vault', categories: ['azure'] },
+ { query: 'Azure Monitor', categories: ['azure'] },
+ { query: 'Azure Kubernetes', categories: ['azure'] },
+ { query: 'App Service', categories: ['azure'] },
+ { query: 'Azure Cache', categories: ['azure'] },
+
+ // GCP Services
{ query: 'Cloud Run', categories: ['gcp'] },
{ query: 'Cloud SQL', categories: ['gcp'] },
{ query: 'Cloud Storage', categories: ['gcp'] },
+ { query: 'Cloud Functions', categories: ['gcp'] },
+ { query: 'BigQuery', categories: ['gcp'] },
+ { query: 'Pub/Sub', categories: ['gcp'] },
+ { query: 'Cloud CDN', categories: ['gcp'] },
+ { query: 'Firestore', categories: ['gcp'] },
+ { query: 'Cloud Build', categories: ['gcp'] },
+ { query: 'Vertex AI', categories: ['gcp'] },
+ { query: 'Memorystore', categories: ['gcp'] },
+ { query: 'GKE', categories: ['gcp'] },
+ { query: 'Cloud Armor', categories: ['gcp'] },
+
+ // CNCF / Kubernetes
{ query: 'Kubernetes', categories: ['cncf'] },
{ query: 'Ingress', categories: ['cncf'] },
- { query: 'Redis' },
- { query: 'Postgres' },
+ { query: 'Envoy', categories: ['cncf'] },
+ { query: 'Istio', categories: ['cncf'] },
+ { query: 'Helm', categories: ['cncf'] },
+ { query: 'Prometheus', categories: ['cncf'] },
+ { query: 'Containerd', categories: ['cncf'] },
+ { query: 'Fluentd', categories: ['cncf'] },
+ { query: 'CoreDNS', categories: ['cncf'] },
+ { query: 'etcd', categories: ['cncf'] },
+ { query: 'Argo', categories: ['cncf'] },
+ { query: 'Linkerd', categories: ['cncf'] },
+
+ // Databases (developer catalog)
+ { query: 'PostgreSQL', categories: ['developer'] },
+ { query: 'Postgres', categories: ['developer'] },
+ { query: 'MySQL', categories: ['developer'] },
+ { query: 'MongoDB', categories: ['developer'] },
+ { query: 'Redis', categories: ['developer'] },
+ { query: 'Elasticsearch', categories: ['developer'] },
+ { query: 'SQLite', categories: ['developer'] },
+ { query: 'MariaDB', categories: ['developer'] },
+ { query: 'Cassandra', categories: ['developer'] },
+ { query: 'Neo4j', categories: ['developer'] },
+ { query: 'Supabase', categories: ['developer'] },
+ { query: 'PlanetScale', categories: ['developer'] },
+
+ // Frameworks & Runtimes
+ { query: 'Express', categories: ['developer'] },
+ { query: 'Node.js', categories: ['developer'] },
+ { query: 'React', categories: ['developer'] },
+ { query: 'Vue', categories: ['developer'] },
+ { query: 'Angular', categories: ['developer'] },
+ { query: 'Svelte', categories: ['developer'] },
+ { query: 'Next.js', categories: ['developer'] },
+ { query: 'Nuxt', categories: ['developer'] },
+ { query: 'Django', categories: ['developer'] },
+ { query: 'Flask', categories: ['developer'] },
+ { query: 'FastAPI', categories: ['developer'] },
+ { query: 'Spring', categories: ['developer'] },
+ { query: 'Rails', categories: ['developer'] },
+ { query: 'Laravel', categories: ['developer'] },
+ { query: 'NestJS', categories: ['developer'] },
+ { query: 'Deno', categories: ['developer'] },
+ { query: 'Bun', categories: ['developer'] },
+ { query: 'Go', categories: ['developer'] },
+ { query: 'Rust', categories: ['developer'] },
+ { query: 'Python', categories: ['developer'] },
+ { query: 'TypeScript', categories: ['developer'] },
+
+ // Infrastructure & DevOps
+ { query: 'Docker', categories: ['developer'] },
+ { query: 'Nginx', categories: ['developer'] },
+ { query: 'RabbitMQ', categories: ['developer'] },
+ { query: 'Kafka', categories: ['developer'] },
+ { query: 'Terraform', categories: ['developer'] },
+ { query: 'Ansible', categories: ['developer'] },
+ { query: 'Jenkins', categories: ['developer'] },
+ { query: 'GitHub', categories: ['developer'] },
+ { query: 'GitLab', categories: ['developer'] },
+ { query: 'Grafana', categories: ['developer'] },
+ { query: 'Consul', categories: ['developer'] },
+ { query: 'Vault', categories: ['developer'] },
+ { query: 'Pulsar', categories: ['developer'] },
+ { query: 'NATS', categories: ['developer'] },
+
+ // Auth & Payments
+ { query: 'Auth0', categories: ['developer'] },
+ { query: 'Keycloak', categories: ['developer'] },
+ { query: 'Firebase', categories: ['developer'] },
+ { query: 'Stripe', categories: ['developer'] },
+ { query: 'Twilio', categories: ['developer'] },
+ { query: 'SendGrid', categories: ['developer'] },
+ { query: 'Cloudflare', categories: ['developer'] },
+ { query: 'Vercel', categories: ['developer'] },
+ { query: 'Netlify', categories: ['developer'] },
+
+ // Generic terms (search all categories)
{ query: 'Queue' },
{ query: 'Database' },
+ { query: 'Cache' },
+ { query: 'Load Balancer' },
+ { query: 'CDN' },
+ { query: 'Storage' },
+ { query: 'Auth' },
+ { query: 'API' },
+ { query: 'Gateway' },
+ { query: 'Monitoring' },
+ { query: 'Logging' },
+ { query: 'Search' },
+ { query: 'Analytics' },
+ { query: 'ML' },
+ { query: 'AI' },
];
function scoreMatch(item: DomainLibraryItem, query: string): number {
@@ -72,8 +202,12 @@ function toGroundingMatch(item: DomainLibraryItem, query: string): AssetGroundin
};
}
-function inferQueriesFromPrompt(prompt: string): Array<{ query: string; categories?: DomainLibraryCategory[] }> {
- const matches = SERVICE_ALIASES.filter((entry) => prompt.toLowerCase().includes(entry.query.toLowerCase()));
+function inferQueriesFromPrompt(
+ prompt: string
+): Array<{ query: string; categories?: DomainLibraryCategory[] }> {
+ const matches = SERVICE_ALIASES.filter((entry) =>
+ prompt.toLowerCase().includes(entry.query.toLowerCase())
+ );
if (matches.length > 0) {
return matches;
}
diff --git a/src/services/geminiSystemInstruction.ts b/src/services/geminiSystemInstruction.ts
index 39df5e56..4157265b 100644
--- a/src/services/geminiSystemInstruction.ts
+++ b/src/services/geminiSystemInstruction.ts
@@ -1,10 +1,12 @@
+import { buildCatalogSummary } from '@/lib/iconMatcher';
+
const EDIT_MODE_PREAMBLE = `
## EDIT MODE โ MODIFYING AN EXISTING DIAGRAM
A CURRENT DIAGRAM block will be provided in OpenFlow DSL. You MUST:
1. Output the COMPLETE updated diagram in OpenFlow DSL โ not just the changed parts
-2. Preserve every node that should remain โ copy its id, type, label, icon, color, and all attributes EXACTLY as they appear in CURRENT DIAGRAM
-3. Use the EXACT same node id for every unchanged node (e.g. if CURRENT DIAGRAM has \`node-abc123: Login Service\`, your output must also use \`node-abc123\`)
+2. Preserve every node that should remain โ copy its id, type, label, and all attributes EXACTLY as they appear in CURRENT DIAGRAM
+3. Use the EXACT same node id for every unchanged node
4. Only change what the user explicitly requested
5. New nodes should have short descriptive IDs (e.g. \`redis_cache\`, \`auth_v2\`)
6. Do NOT re-layout or restructure nodes not affected by the change
@@ -17,193 +19,93 @@ A CURRENT DIAGRAM block will be provided in OpenFlow DSL. You MUST:
const BASE_SYSTEM_INSTRUCTION = `
# OpenFlow DSL Generation System
-You are an expert diagram assistant that converts plain language into **OpenFlow DSL**.
-
-Your job:
-- Read any description of a process, system, or flow โ casual or technical.
-- Use conversation history for context and refinements.
-- If an image is provided, convert the diagram/sketch into OpenFlow DSL.
-- Infer obvious missing steps.
-- Always output **only valid OpenFlow DSL** โ no prose, no explanations, no markdown wrappers.
+You convert plain language into **OpenFlow DSL** diagrams. Output ONLY valid OpenFlow DSL โ no prose, no markdown wrappers.
---
-## Structure Rules
-
-1. Start every diagram with a header:
- \`\`\`
- flow: Title Here
- direction: TB
- \`\`\`
- - Default to \`TB\` (top-to-bottom) for most diagrams.
- - Use \`LR\` (left-to-right) for pipelines, timelines, stages, workflows, or CI/CD.
-
-2. Define all **Nodes first**, then all **Edges**. Never mix them.
- - INVALID: \`[start] A -> [end] B\`
- - VALID: define nodes, then \`A -> B\`
+## Structure
-3. Node ID rules:
- - Short labels โ use label as ID: \`[process] Login { icon: "LogIn" }\`
- - Long labels โ use ID prefix: \`[process] login_step: User enters credentials { icon: "LogIn" }\`
+1. Header: \`flow: Title\` + \`direction: TB\` (default) or \`LR\` (pipelines, CI/CD).
+2. Define ALL nodes first, then ALL edges.
+3. Node IDs: simple labels can be the ID. Long labels need a prefix: \`[process] login_step: User enters credentials\`
---
## Node Types
-| Type | When to use |
+| Type | Use for |
|---|---|
| \`[start]\` | Entry point |
-| \`[end]\` | Terminal state (success or failure) |
-| \`[process]\` | Any action, step, or task |
+| \`[end]\` | Terminal state |
+| \`[process]\` | Action, step, task |
| \`[decision]\` | Branch / conditional |
-| \`[system]\` | Application-level backend service, internal API, business logic component |
-| \`[architecture]\` | Cloud or infrastructure resource such as AWS, Azure, GCP, Kubernetes, network, or security components |
-| \`[browser]\` | Web page / frontend screen |
+| \`[system]\` | Backend service, internal API, business logic |
+| \`[architecture]\` | Cloud/infra resource (AWS, Azure, GCP, K8s) |
+| \`[browser]\` | Web page / frontend |
| \`[mobile]\` | Mobile screen |
| \`[note]\` | Callout / annotation |
---
-## Edge Styles โ use these semantically
+## Edges
-| Syntax | Style | When to use |
-|---|---|---|
-| \`->\` | Normal arrow | Default connection |
-| \`->|label|\` | Labeled arrow | Decision branches โ ALWAYS label Yes/No, Pass/Fail etc. |
-| \`==>\` | **Thick** | Primary happy path / critical route |
-| \`-->\` | Curved | Soft / secondary flow |
-| \`..>\` | Dashed | Optional, error path, alternative, async |
+| Syntax | When |
+|---|---|
+| \`->\` | Default |
+| \`->|label|\` | Decision branches (Yes/No, Pass/Fail) |
+| \`==>\` | Primary/critical path |
+| \`-->\` | Secondary/soft flow |
+| \`..\` | Async, error, optional |
---
-## Node Attributes โ ALWAYS add \`icon\` and \`color\` to every non-start/end node
+## Attributes
+
+Syntax: \`[type] id: Label { icon: "IconName", color: "color", subLabel: "subtitle" }\`
-Syntax: \`[type] id: Label { icon: "IconName", color: "color", subLabel: "optional subtitle" }\`
+For \`[architecture]\` nodes: \`[architecture] id: Label { archProvider: "aws", archResourceType: "lambda", color: "violet" }\`
-For \`[architecture]\` nodes use:
-\`[architecture] id: Label { archProvider: "aws", archResourceType: "service", archIconPackId: "aws-official-starter-v1", color: "violet" }\`
+Colors: \`blue\` (frontend), \`violet\` (backend), \`emerald\` (data), \`amber\` (decisions/queues), \`red\` (errors/end), \`slate\` (generic), \`pink\` (third-party), \`yellow\` (cache).
-- Required attributes for \`[architecture]\`: \`archProvider\`, \`archResourceType\`
-- Optional attributes for \`[architecture]\`: \`archIconPackId\`, \`archIconShapeId\`, \`color\`, \`subLabel\`
-- Prefer \`[architecture]\` over \`[system]\` for cloud services, infrastructure, managed data stores, queues, gateways, network, and security resources
-- Prefer \`[system]\` for application services, internal APIs, controllers, workers, and business logic that belong to the product itself
+Icons are optional โ the system auto-assigns them. For known technologies, use \`archProvider\` and \`archResourceType\` to specify the icon directly:
-6. **subLabel** โ add a short subtitle for context on complex nodes:
- \`\`\`
- [process] auth: Authenticate { icon: "Lock", color: "blue", subLabel: "OAuth 2.0 + JWT" }
- [system] api: Payment API { icon: "CreditCard", color: "violet", subLabel: "Stripe v3" }
- \`\`\`
+\`[system] db: PostgreSQL { archProvider: "developer", archResourceType: "database-postgresql", color: "violet" }\`
-7. **Annotations** โ use \`[note]\` to add callouts for constraints, caveats, or SLAs. Connect with a dashed edge \`..>\`:
- \`\`\`
- [note] sla: 99.9% Uptime required { color: "slate" }
- api ..> sla
- \`\`\`
+Available icon catalog:
+${buildCatalogSummary(15)}
-8. **No container nodes** โ do not use \`[section]\` nodes or \`group {}\` blocks. Keep related nodes near each other and use labels or subtitles to imply layers such as frontend, backend, or data.
+Use exact shape IDs from the catalog when possible (e.g. \`database-postgresql\`, \`queue-rabbitmq\`). If unsure, omit \`archResourceType\` and the system will match by label.
---
-9. **Curated icon list** โ pick the MOST semantically appropriate icon from this list:
-
- Actions: \`Play\`, \`Pause\`, \`Stop\`, \`Check\`, \`X\`, \`Plus\`, \`Trash2\`, \`Edit3\`, \`Send\`, \`Upload\`, \`Download\`, \`Search\`, \`Filter\`, \`RefreshCw\`, \`LogIn\`, \`LogOut\`
-
- Data & Dev: \`Database\`, \`Server\`, \`Code2\`, \`Terminal\`, \`GitBranch\`, \`Zap\`, \`Settings\`, \`Key\`, \`Lock\`, \`Unlock\`, \`ShieldCheck\`, \`AlertTriangle\`
-
- People: \`User\`, \`Users\`, \`UserCheck\`, \`UserPlus\`, \`Bell\`, \`Mail\`, \`Phone\`, \`MessageSquare\`, \`Contact\`
-
- Commerce: \`ShoppingCart\`, \`CreditCard\`, \`Package\`, \`Store\`, \`Tag\`, \`Receipt\`, \`Truck\`
-
- Content: \`File\`, \`FileText\`, \`Folder\`, \`Image\`, \`Link\`, \`Globe\`, \`Rss\`
-
- Infrastructure: \`Cloud\`, \`Wifi\`, \`Smartphone\`, \`Monitor\`, \`HardDrive\`, \`Cpu\`
-
-10. **Cloud provider icons** โ when rendering infrastructure, use \`[architecture]\` nodes and these provider values:
- - AWS: \`archProvider: "aws"\`, prefer \`archIconPackId: "aws-official-starter-v1"\`
- Common services: EC2, S3, RDS, Lambda, DynamoDB, API Gateway, CloudFront, SQS, SNS, ECS, EKS, ElastiCache, Cognito, IAM
- - Azure: \`archProvider: "azure"\`, prefer \`archIconPackId: "azure-official-icons-v20"\`
- Common services: VM, Functions, Storage Account, Azure SQL, API Management, Front Door
- - GCP: \`archProvider: "gcp"\`
- Common services: Compute Engine, Cloud Functions, Cloud Storage, Cloud SQL, Load Balancer, Cloud Run
- - Kubernetes / CNCF: \`archProvider: "cncf"\`
- Common resources: Cluster, Node, Pod, Service, Ingress, ConfigMap
- - Network: \`archProvider: "network"\`
- Common resource types: \`load_balancer\`, \`router\`, \`switch\`, \`cdn\`, \`dns\`, \`service\`
- - Security: \`archProvider: "security"\`
- Common resource types: \`firewall\`, \`service\`, \`dns\`
-
-11. **Color semantics** โ use colors deliberately, not randomly:
- - \`blue\` โ frontend, user-facing, presentation layer
- - \`violet\` โ backend services, APIs, internal systems
- - \`emerald\` โ data stores, persistence, successful outcomes
- - \`amber\` โ queues, async workers, warning states, decisions
- - \`red\` โ security boundaries, firewalls, error, end, fail, danger, cancel
- - \`slate\` โ generic fallback, unknown services, neutral groups
- - \`pink\` โ third-party or external services
- - \`yellow\` โ cache, fast path, in-memory systems
-
-12. **Use node types intentionally**:
- - \`[architecture]\`: cloud services, infrastructure, managed databases, queues, gateways, DNS, CDN, VPN, firewalls
- - \`[system]\`: product-owned backend services, internal APIs, modules, business logic
- - \`[browser]\`: web apps, dashboards, admin panels, portals
- - \`[mobile]\`: iOS, Android, React Native, Flutter apps
- - \`[process]\`: operational steps, jobs, transformations, workflows
- - Do not use container or group nodes for layers, trust boundaries, VPCs, clusters, namespaces, or zones
-
-13. Label important edges with what flows across them, especially in architecture diagrams: \`HTTP/REST\`, \`SQL\`, \`gRPC\`, \`events\`, \`cache lookup\`, \`files\`
-
-14. Use comments \`#\` only when they add clarity.
-
-15. Do NOT explain the output. Do NOT add prose. Only output DSL.
-
-15b. **Diagram density** โ aim for the right density:
- - Flowcharts: 6โ15 nodes is ideal. More than 20 = simplify the diagram.
- - Architecture diagrams: 8โ20 nodes, with layers implied by labels, subtitles, and placement instead of containers.
- - Sequence/journey: 4โ10 steps in the happy path.
- - If a request is simple, keep the diagram simple. Do not pad with unnecessary detail.
-
-15c. **Layout quality rules**:
- - Happy path flows TOP โ BOTTOM (TB) or LEFT โ RIGHT (LR) in a straight line, with alternatives branching off the sides.
- - Decision nodes (\`[decision]\`) should have EXACTLY 2 outgoing labeled edges (e.g. \`->|Yes|\` and \`->|No|\`).
- - Avoid more than 3 incoming edges on any single node โ use a \`[process]\` aggregator if needed.
- - Keep tightly coupled nodes visually close without using container blocks.
- - Name architectural layers directly in node labels or subtitles instead of using container nodes.
- - Use \`==>\` (thick) for the critical path, \`->\` for normal flow, \`..>\` for async/optional, \`-->\` for soft/secondary.
-
-15d. **Self-describing diagrams** โ every diagram should be readable without a legend:
- - Include \`subLabel\` on complex nodes to explain protocols, versions, or constraints.
- - Label important edges with what flows across them: \`HTTP/REST\`, \`SQL query\`, \`JWT\`, \`events\`, \`file\`.
- - Use \`[note]\` nodes for critical constraints, SLAs, or caveats โ connect with \`..>\`.
-
-16. **Node IDs**:
- - If the label is simple (e.g., "Login"), you can use it as the ID: \`[process] Login { icon: "LogIn" }\`.
- - If the label is long, use an ID: \`[process] login_step: User enters credentials { icon: "LogIn" }\`.
-
-17. **Iterative editing โ preserve existing IDs**:
- - When a CURRENT CONTENT block is provided, it includes each node's exact \`id\` (e.g. \`"id": "node-abc123"\`).
- - For nodes that should REMAIN in the diagram, reuse their EXACT id as the node identifier in your DSL output.
- - Example: if context shows \`"id": "node-abc123", "label": "Login"\`, output \`[process] node-abc123: Login { icon: "LogIn", color: "blue" }\`
- - Only introduce new ids for genuinely new nodes you are adding.
- - Omit nodes that should be removed โ do not output them at all.
- - When a FOCUSED EDIT is specified (selected nodes), preserve all non-selected nodes verbatim with their exact IDs and properties.
+## Rules
+
+- Decisions: exactly 2 outgoing labeled edges
+- Max 3 incoming edges per node
+- Label edges with what flows: \`HTTP/REST\`, \`SQL\`, \`events\`, \`JWT\`
+- Use \`subLabel\` for protocols, versions, constraints
+- Use \`[note]\` for SLAs/caveats, connected with \`..\`
+- 6โ15 nodes for flowcharts, 8โ20 for architecture
+- Do NOT use container/group nodes
+- When editing, preserve existing node IDs exactly
---
## Examples
-### User Authentication
+### Authentication Flow
\`\`\`
flow: User Authentication
direction: TB
[start] Start
-[process] login: Login Form { icon: "LogIn", color: "blue", subLabel: "Email + password" }
-[decision] valid: Credentials valid? { icon: "ShieldCheck", color: "amber" }
-[process] mfa: MFA Check { icon: "Smartphone", color: "blue", subLabel: "TOTP / SMS" }
-[process] token: Issue JWT { icon: "Key", color: "violet" }
-[end] dashboard: Enter Dashboard { icon: "Monitor", color: "emerald" }
-[end] fail: Access Denied { icon: "X", color: "red" }
+[process] login: Login Form { icon: "LogIn", color: "blue" }
+[decision] valid: Credentials valid? { color: "amber" }
+[process] mfa: MFA Check { icon: "Smartphone", color: "blue" }
+[system] token: Issue JWT { icon: "Key", color: "violet" }
+[end] dashboard: Enter Dashboard { color: "emerald" }
+[end] fail: Access Denied { color: "red" }
Start ==> login
login -> valid
@@ -213,80 +115,40 @@ mfa ==> token
token ==> dashboard
\`\`\`
-### E-Commerce Checkout
+### AWS Serverless Architecture
\`\`\`
-flow: Checkout Flow
+flow: Serverless API
direction: TB
-[start] Start
-[process] cart: Review Cart { icon: "ShoppingCart", color: "blue" }
-[process] address: Shipping Address { icon: "Truck", color: "blue" }
-[process] payment: Payment Details { icon: "CreditCard", color: "blue", subLabel: "Stripe v3" }
-[decision] fraud: Fraud check { icon: "ShieldCheck", color: "amber" }
-[system] fulfil: Fulfilment Service { icon: "Package", color: "violet" }
-[process] notify: Send Confirmation { icon: "Mail", color: "emerald", subLabel: "Email + SMS" }
-[end] done: Order Complete { icon: "Check", color: "emerald" }
-[end] declined: Payment Declined { icon: "AlertTriangle", color: "red" }
-
-Start ==> cart
-cart ==> address
-address ==> payment
-payment -> fraud
-fraud ->|Pass| fulfil
-fraud ->|Fail| declined
-fulfil ==> notify
-notify ==> done
-\`\`\`
-
-### CI/CD Pipeline
+[architecture] cf: CloudFront { archProvider: "aws", archResourceType: "networking-cloudfront", color: "blue" }
+[architecture] apigw: API Gateway { archProvider: "aws", archResourceType: "app-integration-api-gateway", color: "violet" }
+[architecture] lambda: API Lambda { archProvider: "aws", archResourceType: "compute-lambda", color: "violet" }
+[architecture] dynamo: DynamoDB { archProvider: "aws", archResourceType: "database-dynamodb", color: "emerald" }
+[architecture] cache: ElastiCache { archProvider: "aws", archResourceType: "database-elasticache", color: "yellow" }
-\`\`\`
-flow: CI/CD Pipeline
-direction: LR
-
-[start] Push
-[process] build: Build { icon: "Code2", color: "blue", subLabel: "npm run build" }
-[process] test: Run Tests { icon: "Check", color: "blue", subLabel: "Jest + Playwright" }
-[decision] pass: All tests pass? { icon: "GitBranch", color: "amber" }
-[system] registry: Push to Registry { icon: "Cloud", color: "violet", subLabel: "Docker Hub" }
-[process] deploy: Deploy to Production { icon: "Zap", color: "emerald" }
-[process] slack_notify: Slack Notification { icon: "MessageSquare", color: "blue" }
-[end] live: Live { icon: "Globe", color: "emerald" }
-[end] failed: Build Failed { icon: "X", color: "red" }
-
-Push ==> build
-build ==> test
-test -> pass
-pass ->|Yes| registry
-pass ->|No| failed
-registry ==> deploy
-deploy ..> slack_notify
-slack_notify ==> live
+cf ->|HTTPS| apigw
+apigw ->|HTTP/REST| lambda
+lambda ->|query| dynamo
+lambda ->|cache lookup| cache
\`\`\`
-### Architecture Diagram
+### Full-Stack with Developer Icons
\`\`\`
-flow: Serverless API - AWS
+flow: E-Commerce Stack
direction: TB
-[architecture] cf: CloudFront { archProvider: "aws", archResourceType: "cdn", archIconPackId: "aws-official-starter-v1", color: "blue" }
-[architecture] apigw: API Gateway { archProvider: "aws", archResourceType: "service", archIconPackId: "aws-official-starter-v1", color: "violet", subLabel: "Edge Layer" }
-[architecture] auth_fn: Auth Lambda { archProvider: "aws", archResourceType: "service", archIconPackId: "aws-official-starter-v1", color: "violet" }
-[architecture] api_fn: API Lambda { archProvider: "aws", archResourceType: "service", archIconPackId: "aws-official-starter-v1", color: "violet", subLabel: "Compute Layer" }
-[architecture] dynamo: DynamoDB { archProvider: "aws", archResourceType: "database", archIconPackId: "aws-official-starter-v1", color: "emerald" }
-[architecture] cache: ElastiCache { archProvider: "aws", archResourceType: "service", archIconPackId: "aws-official-starter-v1", color: "yellow" }
-[architecture] s3: S3 Storage { archProvider: "aws", archResourceType: "service", archIconPackId: "aws-official-starter-v1", color: "emerald", subLabel: "Data Layer" }
-[architecture] cognito: Cognito { archProvider: "aws", archResourceType: "service", archIconPackId: "aws-official-starter-v1", color: "amber" }
+[system] react: React App { archProvider: "developer", archResourceType: "frontend-react", color: "blue" }
+[system] api: Express API { archProvider: "developer", archResourceType: "others-expressjs-dark", color: "violet" }
+[system] db: PostgreSQL { archProvider: "developer", archResourceType: "database-postgresql", color: "violet" }
+[system] cache: Redis { archProvider: "developer", archResourceType: "database-redis", color: "red" }
+[system] mq: RabbitMQ { archProvider: "developer", archResourceType: "queue-rabbitmq", color: "amber" }
-cf ->|HTTPS| apigw
-apigw ->|auth request| auth_fn
-apigw ->|HTTP/REST| api_fn
-auth_fn ->|identity| cognito
-api_fn ->|query| dynamo
-api_fn ->|cache lookup| cache
-api_fn ->|store files| s3
+react ->|HTTP/REST| api
+api ->|SQL| db
+api ->|cache lookup| cache
+api ->|publish| mq
\`\`\`
`;
diff --git a/src/services/importFidelity.test.ts b/src/services/importFidelity.test.ts
index 8822a68d..53b7001b 100644
--- a/src/services/importFidelity.test.ts
+++ b/src/services/importFidelity.test.ts
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest';
import {
buildImportFidelityReport,
+ getImportRecoveryGuidance,
mapErrorToIssue,
+ mapMermaidDiagnosticToIssue,
mapParserDiagnosticToIssue,
mapWarningToIssue,
summarizeImportReport,
@@ -26,6 +28,20 @@ describe('importFidelity', () => {
expect(issue.line).toBe(4);
});
+ it('maps Mermaid structured diagnostics to fidelity issues', () => {
+ const issue = mapMermaidDiagnosticToIssue({
+ code: 'MERMAID_SYNTAX',
+ severity: 'warning',
+ message: 'Invalid class declaration',
+ line: 6,
+ editableImpact: 'partial',
+ });
+
+ expect(issue.code).toBe('MERMAID_SYNTAX');
+ expect(issue.severity).toBe('warning');
+ expect(issue.line).toBe(6);
+ });
+
it('builds and summarizes import report', () => {
const report = buildImportFidelityReport({
source: 'json',
@@ -41,4 +57,20 @@ describe('importFidelity', () => {
expect(report.summary.errorCount).toBe(0);
expect(summarizeImportReport(report)).toContain('JSON import');
});
+
+ it('includes Mermaid import state in summaries', () => {
+ const report = buildImportFidelityReport({
+ source: 'mermaid',
+ importState: 'editable_partial',
+ originalSource: 'flowchart TD\nA-->B',
+ nodeCount: 2,
+ edgeCount: 1,
+ elapsedMs: 9,
+ issues: [{ code: 'MERMAID_SYNTAX', severity: 'warning', message: 'diag' }],
+ });
+
+ expect(report.originalSource).toContain('flowchart TD');
+ expect(summarizeImportReport(report)).toContain('Ready with warnings');
+ expect(getImportRecoveryGuidance(report)).toContain('fully editable');
+ });
});
diff --git a/src/services/importFidelity.ts b/src/services/importFidelity.ts
index 61d347b2..c77df0c3 100644
--- a/src/services/importFidelity.ts
+++ b/src/services/importFidelity.ts
@@ -1,4 +1,13 @@
import type { ParseDiagnostic } from '@/lib/openFlowDSLParser';
+import type {
+ MermaidImportDiagnostic,
+ MermaidImportStatus,
+} from '@/services/mermaid/importContracts';
+import {
+ getMermaidImportStateDetail,
+ getMermaidImportStateGuidance,
+ getMermaidImportStateLabel,
+} from '@/services/mermaid/importStatePresentation';
import { writeLocalStorageJson } from '@/services/storage/uiLocalStorage';
export type ImportSource = 'json' | 'mermaid' | 'openflowdsl' | 'drawio' | 'visio';
@@ -16,6 +25,8 @@ export interface ImportIssue {
export interface ImportFidelityReport {
id: string;
source: ImportSource;
+ importState?: MermaidImportStatus;
+ originalSource?: string;
timestamp: string;
status: 'success' | 'success_with_warnings' | 'failed';
nodeCount: number;
@@ -72,8 +83,21 @@ export function mapParserDiagnosticToIssue(diagnostic: ParseDiagnostic): ImportI
};
}
+export function mapMermaidDiagnosticToIssue(diagnostic: MermaidImportDiagnostic): ImportIssue {
+ return {
+ code: diagnostic.code,
+ severity: diagnostic.severity,
+ message: diagnostic.message,
+ line: diagnostic.line,
+ snippet: diagnostic.snippet,
+ hint: diagnostic.hint,
+ };
+}
+
export function buildImportFidelityReport(params: {
source: ImportSource;
+ importState?: MermaidImportStatus;
+ originalSource?: string;
nodeCount: number;
edgeCount: number;
elapsedMs: number;
@@ -86,6 +110,8 @@ export function buildImportFidelityReport(params: {
return {
id: createReportId(),
source: params.source,
+ importState: params.importState,
+ originalSource: params.originalSource,
timestamp: new Date().toISOString(),
status,
nodeCount: params.nodeCount,
@@ -105,5 +131,26 @@ export function persistLatestImportReport(report: ImportFidelityReport): void {
export function summarizeImportReport(report: ImportFidelityReport): string {
const { warningCount, errorCount } = report.summary;
+ if (report.source === 'mermaid') {
+ const label = getMermaidImportStateLabel(report.importState);
+ const detail = getMermaidImportStateDetail({
+ importState: report.importState,
+ nodeCount: report.nodeCount,
+ edgeCount: report.edgeCount,
+ });
+ return `MERMAID import: ${label} (${detail}, ${warningCount} warnings, ${errorCount} errors)`;
+ }
+
return `${report.source.toUpperCase()} import: ${report.status} (${report.nodeCount} nodes, ${report.edgeCount} edges, ${warningCount} warnings, ${errorCount} errors)`;
}
+
+export function getImportRecoveryGuidance(report: ImportFidelityReport): string {
+ if (report.source === 'mermaid') {
+ return (
+ getMermaidImportStateGuidance(report.importState)
+ ?? 'Review the Mermaid diagnostics, then retry with simplified or corrected Mermaid code.'
+ );
+ }
+
+ return 'If this file came from another tool, try exporting a plain JSON/OpenFlowKit file again or remove unsupported metadata before retrying.';
+}
diff --git a/src/services/importLayoutMetadata.ts b/src/services/importLayoutMetadata.ts
new file mode 100644
index 00000000..30568e75
--- /dev/null
+++ b/src/services/importLayoutMetadata.ts
@@ -0,0 +1,94 @@
+import type { FlowNode } from '@/lib/types';
+import type { LayoutOptions } from '@/services/elk-layout/types';
+
+export interface ImportLayoutMetadata {
+ signature: string;
+ direction: NonNullable;
+ spacing: NonNullable;
+ contentDensity: NonNullable;
+ diagramType?: string;
+}
+
+const IMPORT_PENDING_LAYOUT_KEY = '_importPendingLayout';
+const IMPORT_LAYOUT_SIGNATURE_KEY = '_importLayoutSignature';
+const IMPORT_LAYOUT_DIRECTION_KEY = '_importLayoutDirection';
+const IMPORT_LAYOUT_SPACING_KEY = '_importLayoutSpacing';
+const IMPORT_LAYOUT_CONTENT_DENSITY_KEY = '_importLayoutContentDensity';
+const IMPORT_LAYOUT_DIAGRAM_TYPE_KEY = '_importLayoutDiagramType';
+
+export function attachImportLayoutMetadata(
+ nodes: FlowNode[],
+ metadata: ImportLayoutMetadata
+): FlowNode[] {
+ return nodes.map((node) => ({
+ ...node,
+ data: {
+ ...node.data,
+ [IMPORT_PENDING_LAYOUT_KEY]: true,
+ [IMPORT_LAYOUT_SIGNATURE_KEY]: metadata.signature,
+ [IMPORT_LAYOUT_DIRECTION_KEY]: metadata.direction,
+ [IMPORT_LAYOUT_SPACING_KEY]: metadata.spacing,
+ [IMPORT_LAYOUT_CONTENT_DENSITY_KEY]: metadata.contentDensity,
+ [IMPORT_LAYOUT_DIAGRAM_TYPE_KEY]: metadata.diagramType,
+ },
+ }));
+}
+
+export function clearImportLayoutMetadata(nodes: FlowNode[]): FlowNode[] {
+ return nodes.map((node) => {
+ if (!isImportPendingLayoutNode(node)) {
+ return node;
+ }
+
+ const data = { ...node.data };
+ delete data[IMPORT_PENDING_LAYOUT_KEY];
+ delete data[IMPORT_LAYOUT_SIGNATURE_KEY];
+ delete data[IMPORT_LAYOUT_DIRECTION_KEY];
+ delete data[IMPORT_LAYOUT_SPACING_KEY];
+ delete data[IMPORT_LAYOUT_CONTENT_DENSITY_KEY];
+ delete data[IMPORT_LAYOUT_DIAGRAM_TYPE_KEY];
+
+ return {
+ ...node,
+ data,
+ };
+ });
+}
+
+export function isImportPendingLayoutNode(node: FlowNode): boolean {
+ return node.data?.[IMPORT_PENDING_LAYOUT_KEY] === true;
+}
+
+export function readImportLayoutMetadata(nodes: FlowNode[]): ImportLayoutMetadata | null {
+ const node = nodes.find(isImportPendingLayoutNode);
+ if (!node) {
+ return null;
+ }
+
+ const signature = node.data?.[IMPORT_LAYOUT_SIGNATURE_KEY];
+ const direction = node.data?.[IMPORT_LAYOUT_DIRECTION_KEY];
+ const spacing = node.data?.[IMPORT_LAYOUT_SPACING_KEY];
+ const contentDensity = node.data?.[IMPORT_LAYOUT_CONTENT_DENSITY_KEY];
+
+ if (
+ typeof signature !== 'string'
+ || (direction !== 'TB' && direction !== 'LR' && direction !== 'RL' && direction !== 'BT')
+ || (spacing !== 'compact' && spacing !== 'normal' && spacing !== 'loose')
+ || (contentDensity !== 'compact' && contentDensity !== 'balanced' && contentDensity !== 'verbose')
+ ) {
+ return null;
+ }
+
+ const diagramType =
+ typeof node.data?.[IMPORT_LAYOUT_DIAGRAM_TYPE_KEY] === 'string'
+ ? String(node.data?.[IMPORT_LAYOUT_DIAGRAM_TYPE_KEY])
+ : undefined;
+
+ return {
+ signature,
+ direction,
+ spacing,
+ contentDensity,
+ diagramType,
+ };
+}
diff --git a/src/services/mermaid/compatFixtureCorpus.test.ts b/src/services/mermaid/compatFixtureCorpus.test.ts
new file mode 100644
index 00000000..0858b249
--- /dev/null
+++ b/src/services/mermaid/compatFixtureCorpus.test.ts
@@ -0,0 +1,139 @@
+import { describe, expect, it } from 'vitest';
+import { parseMermaidByType } from './parseMermaidByType';
+import type { MermaidImportStatus } from './importContracts';
+import { MERMAID_COMPAT_FIXTURES } from '../../../scripts/mermaid-compat-fixtures.mjs';
+
+interface MermaidCompatFixture {
+ name: string;
+ family: string;
+ bucket: 'editable_full' | 'editable_partial' | 'valid_but_not_editable' | 'invalid_source';
+ expectedOfficial: 'valid' | 'invalid' | 'environment_limited';
+ expectedEditableGate: 'supported_family' | 'unsupported_family' | 'invalid_source';
+ expectedImportState: MermaidImportStatus;
+ source: string;
+ structuralAssertions?: {
+ minNodes?: number;
+ maxNodes?: number;
+ minEdges?: number;
+ maxEdges?: number;
+ diagnosticsMin?: number;
+ minSections?: number;
+ minParticipants?: number;
+ minNotes?: number;
+ minAnnotations?: number;
+ requiredLabels?: string[];
+ requiredNodeIds?: string[];
+ requiredParentIds?: Record;
+ };
+}
+
+function countNodesOfType(nodes: Array<{ type?: string }>, type: string): number {
+ return nodes.filter((node) => node.type === type).length;
+}
+
+function shouldExposeDiagramType(importState: MermaidImportStatus): boolean {
+ return importState !== 'invalid_source' && importState !== 'unsupported_family';
+}
+
+function hasDegradingDiagnostics(
+ diagnostics: Array<{ severity?: string; editableImpact?: string }> | undefined
+): boolean {
+ return (diagnostics ?? []).some(
+ (diagnostic) =>
+ diagnostic.severity === 'warning'
+ || diagnostic.editableImpact === 'partial'
+ || diagnostic.editableImpact === 'blocked'
+ );
+}
+
+describe('Mermaid compat fixture corpus', () => {
+ it('enforces import-state and structure expectations for the shared fixture corpus', () => {
+ const fixtures = MERMAID_COMPAT_FIXTURES as MermaidCompatFixture[];
+
+ for (const fixture of fixtures) {
+ const result = parseMermaidByType(fixture.source);
+
+ expect(result.originalSource?.trim(), fixture.name).toBe(fixture.source.trim());
+ expect(result.importState, fixture.name).toBe(fixture.expectedImportState);
+
+ if (shouldExposeDiagramType(fixture.expectedImportState)) {
+ expect(result.diagramType, fixture.name).toBeDefined();
+ }
+
+ if (fixture.expectedEditableGate === 'unsupported_family') {
+ expect(result.importState, fixture.name).toBe('unsupported_family');
+ }
+
+ if (fixture.expectedImportState !== 'editable_full') {
+ expect(result.originalSource, fixture.name).toContain(fixture.source.trim().split('\n')[0]);
+ }
+ if (fixture.expectedImportState === 'editable_full') {
+ expect(hasDegradingDiagnostics(result.structuredDiagnostics), fixture.name).toBe(false);
+ } else if (fixture.expectedImportState === 'editable_partial') {
+ expect(hasDegradingDiagnostics(result.structuredDiagnostics), fixture.name).toBe(true);
+ }
+
+ const assertions = fixture.structuralAssertions;
+ if (!assertions) {
+ continue;
+ }
+
+ if (typeof assertions.minNodes === 'number') {
+ expect(result.nodes.length, fixture.name).toBeGreaterThanOrEqual(assertions.minNodes);
+ }
+ if (typeof assertions.maxNodes === 'number') {
+ expect(result.nodes.length, fixture.name).toBeLessThanOrEqual(assertions.maxNodes);
+ }
+ if (typeof assertions.minEdges === 'number') {
+ expect(result.edges.length, fixture.name).toBeGreaterThanOrEqual(assertions.minEdges);
+ }
+ if (typeof assertions.maxEdges === 'number') {
+ expect(result.edges.length, fixture.name).toBeLessThanOrEqual(assertions.maxEdges);
+ }
+ if (typeof assertions.diagnosticsMin === 'number') {
+ expect(result.structuredDiagnostics?.length ?? 0, fixture.name).toBeGreaterThanOrEqual(
+ assertions.diagnosticsMin
+ );
+ }
+ if (typeof assertions.minSections === 'number') {
+ expect(countNodesOfType(result.nodes, 'section'), fixture.name).toBeGreaterThanOrEqual(
+ assertions.minSections
+ );
+ }
+ if (typeof assertions.minParticipants === 'number') {
+ expect(
+ countNodesOfType(result.nodes, 'sequence_participant'),
+ fixture.name
+ ).toBeGreaterThanOrEqual(assertions.minParticipants);
+ }
+ if (typeof assertions.minNotes === 'number') {
+ expect(countNodesOfType(result.nodes, 'sequence_note'), fixture.name).toBeGreaterThanOrEqual(
+ assertions.minNotes
+ );
+ }
+ if (typeof assertions.minAnnotations === 'number') {
+ expect(countNodesOfType(result.nodes, 'annotation'), fixture.name).toBeGreaterThanOrEqual(
+ assertions.minAnnotations
+ );
+ }
+ for (const label of assertions.requiredLabels ?? []) {
+ expect(
+ result.nodes.some((node) => String(node.data?.label ?? '').includes(label)),
+ `${fixture.name} should preserve label "${label}"`
+ ).toBe(true);
+ }
+ for (const nodeId of assertions.requiredNodeIds ?? []) {
+ expect(
+ result.nodes.some((node) => node.id === nodeId),
+ `${fixture.name} should preserve node id "${nodeId}"`
+ ).toBe(true);
+ }
+ for (const [nodeId, parentId] of Object.entries(assertions.requiredParentIds ?? {})) {
+ expect(
+ result.nodes.find((node) => node.id === nodeId)?.parentId,
+ `${fixture.name} should preserve parent "${parentId}" for node "${nodeId}"`
+ ).toBe(parentId);
+ }
+ }
+ });
+});
diff --git a/src/services/mermaid/compatReportHarness.test.ts b/src/services/mermaid/compatReportHarness.test.ts
new file mode 100644
index 00000000..f41da811
--- /dev/null
+++ b/src/services/mermaid/compatReportHarness.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it } from 'vitest';
+import { execFileSync } from 'node:child_process';
+import { parseMermaidByType } from './parseMermaidByType';
+import type { MermaidImportStatus } from './importContracts';
+import { MERMAID_COMPAT_FIXTURES } from '../../../scripts/mermaid-compat-fixtures.mjs';
+
+describe('mermaid compat report harness', () => {
+ it('emits corpus-driven family summary output', () => {
+ const output = execFileSync('node', ['scripts/mermaid-compat-report.mjs'], {
+ cwd: process.cwd(),
+ encoding: 'utf8',
+ });
+ const report = JSON.parse(output);
+
+ expect(report.summary.totalFixtures).toBeGreaterThanOrEqual(36);
+ expect(report.summary.supportedFamilies).toBeGreaterThan(0);
+ expect(report.summary.officialExpectationMatches).toBeGreaterThan(0);
+ expect(Array.isArray(report.familySummary)).toBe(true);
+ expect(report.familySummary).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ family: 'flowchart' }),
+ expect.objectContaining({ family: 'sequence' }),
+ expect.objectContaining({ family: 'stateDiagram' }),
+ expect.objectContaining({ family: 'classDiagram' }),
+ expect.objectContaining({ family: 'erDiagram' }),
+ expect.objectContaining({ family: 'mindmap' }),
+ expect.objectContaining({ family: 'journey' }),
+ ])
+ );
+ });
+
+ it('measures actual OpenFlowKit import outcomes for the fixture corpus', () => {
+ const fixtures = MERMAID_COMPAT_FIXTURES as Array<{
+ name: string;
+ family: string;
+ expectedImportState: MermaidImportStatus;
+ expectedOfficial: 'valid' | 'invalid' | 'environment_limited';
+ expectedEditableGate: 'supported_family' | 'unsupported_family' | 'invalid_source';
+ source: string;
+ }>;
+
+ const outcomeCounts = {
+ editable_full: 0,
+ editable_partial: 0,
+ unsupported_construct: 0,
+ unsupported_family: 0,
+ invalid_source: 0,
+ } as Record;
+
+ for (const fixture of fixtures) {
+ const result = parseMermaidByType(fixture.source);
+ outcomeCounts[result.importState ?? 'invalid_source'] += 1;
+
+ expect(result.originalSource?.trim(), fixture.name).toBe(fixture.source.trim());
+ expect(Array.isArray(result.structuredDiagnostics), fixture.name).toBe(true);
+ expect(result.importState, fixture.name).toBe(fixture.expectedImportState);
+
+ if (fixture.expectedEditableGate === 'unsupported_family') {
+ expect(result.importState, fixture.name).toBe('unsupported_family');
+ continue;
+ }
+
+ if (fixture.expectedEditableGate === 'invalid_source') {
+ expect(result.importState, fixture.name).toBe('invalid_source');
+ continue;
+ }
+
+ expect(result.importState, fixture.name).not.toBe('unsupported_family');
+
+ if (fixture.expectedOfficial === 'valid') {
+ expect(result.diagramType, fixture.name).toBeDefined();
+ }
+ }
+
+ expect(
+ outcomeCounts.editable_full
+ + outcomeCounts.editable_partial
+ + outcomeCounts.unsupported_construct
+ ).toBeGreaterThan(0);
+ expect(outcomeCounts.unsupported_family).toBeGreaterThan(0);
+ expect(outcomeCounts.invalid_source).toBeGreaterThan(0);
+ });
+});
diff --git a/src/services/mermaid/detectDiagramType.test.ts b/src/services/mermaid/detectDiagramType.test.ts
index 5afed97a..c09ffa71 100644
--- a/src/services/mermaid/detectDiagramType.test.ts
+++ b/src/services/mermaid/detectDiagramType.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { detectMermaidDiagramType } from './detectDiagramType';
+import { detectMermaidDiagramType, extractMermaidDiagramHeader } from './detectDiagramType';
describe('detectMermaidDiagramType', () => {
it('detects flowchart and graph headers', () => {
@@ -14,10 +14,10 @@ describe('detectMermaidDiagramType', () => {
it('detects target q2 families', () => {
expect(detectMermaidDiagramType('classDiagram\nA <|-- B')).toBe('classDiagram');
expect(detectMermaidDiagramType('erDiagram\nA ||--o{ B : has')).toBe('erDiagram');
- expect(detectMermaidDiagramType('gitGraph\ncommit')).toBe('gitGraph');
expect(detectMermaidDiagramType('mindmap\nroot')).toBe('mindmap');
expect(detectMermaidDiagramType('journey\ntitle Onboarding')).toBe('journey');
expect(detectMermaidDiagramType('architecture-beta\nservice api')).toBe('architecture');
+ expect(detectMermaidDiagramType('sequenceDiagram\nparticipant A')).toBe('sequence');
});
it('skips empty and comment lines', () => {
@@ -34,5 +34,11 @@ A --> B
expect(detectMermaidDiagramType('A --> B')).toBeNull();
expect(detectMermaidDiagramType('')).toBeNull();
});
-});
+ it('preserves unsupported Mermaid headers for higher-level routing', () => {
+ expect(extractMermaidDiagramHeader('gitGraph\ncommit id: "A"')).toEqual({
+ rawType: 'gitGraph',
+ });
+ expect(detectMermaidDiagramType('gitGraph\ncommit id: "A"')).toBeNull();
+ });
+});
diff --git a/src/services/mermaid/detectDiagramType.ts b/src/services/mermaid/detectDiagramType.ts
index b7d5a7b2..4dba6d93 100644
--- a/src/services/mermaid/detectDiagramType.ts
+++ b/src/services/mermaid/detectDiagramType.ts
@@ -4,25 +4,49 @@ function getRelevantLine(line: string): string {
return line.trim();
}
-export function detectMermaidDiagramType(input: string): DiagramType | null {
+function mapHeaderToDiagramType(header: string): DiagramType | null {
+ if (/^(?:flowchart|graph)$/i.test(header)) return 'flowchart';
+ if (/^stateDiagram(?:-v2)?$/i.test(header)) return 'stateDiagram';
+ if (/^classDiagram$/i.test(header)) return 'classDiagram';
+ if (/^erDiagram$/i.test(header)) return 'erDiagram';
+ if (/^mindmap$/i.test(header)) return 'mindmap';
+ if (/^journey$/i.test(header)) return 'journey';
+ if (/^architecture(?:-beta)?$/i.test(header)) return 'architecture';
+ if (/^sequenceDiagram$/i.test(header)) return 'sequence';
+ return null;
+}
+
+export function extractMermaidDiagramHeader(
+ input: string
+): { rawType?: string; diagramType?: DiagramType } {
const lines = input.replace(/\r\n/g, '\n').split('\n');
for (const rawLine of lines) {
const line = getRelevantLine(rawLine);
if (!line || line.startsWith('%%')) continue;
- if (/^(?:flowchart|graph)\b/i.test(line)) return 'flowchart';
- if (/^stateDiagram(?:-v2)?\b/i.test(line)) return 'stateDiagram';
- if (/^classDiagram\b/i.test(line)) return 'classDiagram';
- if (/^erDiagram\b/i.test(line)) return 'erDiagram';
- if (/^gitGraph\b/i.test(line)) return 'gitGraph';
- if (/^mindmap\b/i.test(line)) return 'mindmap';
- if (/^journey\b/i.test(line)) return 'journey';
- if (/^architecture(?:-beta)?\b/i.test(line)) return 'architecture';
+ const headerMatch = line.match(/^([A-Za-z][\w-]*)\b/);
+ if (!headerMatch) {
+ return {};
+ }
+
+ const rawType = headerMatch[1];
+ const diagramType = mapHeaderToDiagramType(rawType) ?? undefined;
+ if (diagramType) {
+ return { rawType, diagramType };
+ }
- return null;
+ const trailing = line.slice(headerMatch[0].length).trim();
+ if (!trailing) {
+ return { rawType };
+ }
+
+ return {};
}
- return null;
+ return {};
}
+export function detectMermaidDiagramType(input: string): DiagramType | null {
+ return extractMermaidDiagramHeader(input).diagramType ?? null;
+}
diff --git a/src/services/mermaid/diagnosticsSnapshot.test.ts b/src/services/mermaid/diagnosticsSnapshot.test.ts
new file mode 100644
index 00000000..41bcd382
--- /dev/null
+++ b/src/services/mermaid/diagnosticsSnapshot.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from 'vitest';
+import { buildMermaidDiagnosticsSnapshot } from './diagnosticsSnapshot';
+
+describe('diagnosticsSnapshot', () => {
+ it('builds a partial-editability snapshot with derived status fields', () => {
+ const snapshot = buildMermaidDiagnosticsSnapshot({
+ source: 'paste',
+ diagramType: 'flowchart',
+ importState: 'editable_partial',
+ originalSource: 'flowchart TD\nA-->B',
+ diagnostics: [{ message: 'warning' }],
+ nodeCount: 3,
+ edgeCount: 2,
+ });
+
+ expect(snapshot.statusLabel).toBe('Ready with warnings');
+ expect(snapshot.statusDetail).toBe('3 nodes, 2 edges, partial editability (edge syntax edge cases)');
+ expect(snapshot.originalSource).toContain('flowchart TD');
+ expect(snapshot.error).toBeUndefined();
+ });
+
+ it('adds fallback guidance to blocked snapshots', () => {
+ const snapshot = buildMermaidDiagnosticsSnapshot({
+ source: 'code',
+ importState: 'unsupported_family',
+ diagnostics: [],
+ error: 'Mermaid "gitGraph" is not supported yet in editable mode.',
+ });
+
+ expect(snapshot.error).toContain('not editable yet');
+ expect(snapshot.statusLabel).toBe('Unsupported Mermaid family');
+ });
+});
diff --git a/src/services/mermaid/diagnosticsSnapshot.ts b/src/services/mermaid/diagnosticsSnapshot.ts
new file mode 100644
index 00000000..1e38c1a4
--- /dev/null
+++ b/src/services/mermaid/diagnosticsSnapshot.ts
@@ -0,0 +1,51 @@
+import type { DiagramType } from '@/lib/types';
+import type { ParseDiagnostic } from '@/lib/openFlowDSLParser';
+import type { MermaidDiagnosticsSnapshot } from '@/store/types';
+import type { MermaidImportStatus } from './importContracts';
+import {
+ appendMermaidImportGuidance,
+ getMermaidImportStateDetail,
+ getMermaidImportStateLabel,
+} from './importStatePresentation';
+
+interface BuildMermaidDiagnosticsSnapshotParams {
+ source: MermaidDiagnosticsSnapshot['source'];
+ diagramType?: DiagramType;
+ importState?: MermaidImportStatus;
+ originalSource?: string;
+ diagnostics: ParseDiagnostic[];
+ error?: string;
+ nodeCount?: number;
+ edgeCount?: number;
+}
+
+export function buildMermaidDiagnosticsSnapshot(
+ params: BuildMermaidDiagnosticsSnapshotParams
+): MermaidDiagnosticsSnapshot {
+ const statusLabel = getMermaidImportStateLabel(params.importState);
+ const nodeCount = params.nodeCount ?? 0;
+ const edgeCount = params.edgeCount ?? 0;
+
+ return {
+ source: params.source,
+ diagramType: params.diagramType,
+ importState: params.importState,
+ statusLabel,
+ statusDetail: getMermaidImportStateDetail({
+ importState: params.importState,
+ diagramType: params.diagramType,
+ nodeCount,
+ edgeCount,
+ }),
+ originalSource: params.originalSource,
+ diagnostics: params.diagnostics,
+ error: params.error
+ ? appendMermaidImportGuidance({
+ message: params.error,
+ importState: params.importState,
+ diagramType: params.diagramType,
+ })
+ : undefined,
+ updatedAt: Date.now(),
+ };
+}
diff --git a/src/services/mermaid/editablePartialCorpus.test.ts b/src/services/mermaid/editablePartialCorpus.test.ts
new file mode 100644
index 00000000..ed166e53
--- /dev/null
+++ b/src/services/mermaid/editablePartialCorpus.test.ts
@@ -0,0 +1,157 @@
+import { describe, expect, it } from 'vitest';
+import { parseMermaidByType } from './parseMermaidByType';
+
+interface PartialCorpusCase {
+ name: string;
+ source: string;
+ diagramType:
+ | 'flowchart'
+ | 'stateDiagram'
+ | 'classDiagram'
+ | 'erDiagram'
+ | 'architecture'
+ | 'sequence'
+ | 'mindmap'
+ | 'journey';
+ diagnosticIncludes: string[];
+ expectedDiagnosticCodes?: string[];
+}
+
+const PARTIAL_CORPUS: PartialCorpusCase[] = [
+ {
+ name: 'flowchart malformed structure still recovers',
+ diagramType: 'flowchart',
+ source: `
+ flowchart TD
+ subgraph
+ A --> B
+ stray words
+ end
+ end
+ `,
+ diagnosticIncludes: [
+ 'Invalid flowchart subgraph declaration at line',
+ 'Unexpected flowchart block closer at line',
+ ],
+ expectedDiagnosticCodes: ['MERMAID_SYNTAX'],
+ },
+ {
+ name: 'state invalid direction still recovers',
+ diagramType: 'stateDiagram',
+ source: `
+ stateDiagram-v2
+ direction RLX
+ [*] --> Idle
+ `,
+ diagnosticIncludes: ['Invalid stateDiagram direction syntax at line'],
+ expectedDiagnosticCodes: ['MERMAID_SYNTAX'],
+ },
+ {
+ name: 'class malformed relation still recovers',
+ diagramType: 'classDiagram',
+ source: `
+ classDiagram
+ class User
+ User -> Account
+ stray words
+ `,
+ diagnosticIncludes: [
+ 'Invalid class relation syntax at line',
+ 'Unrecognized classDiagram line at line',
+ ],
+ expectedDiagnosticCodes: ['MERMAID_SYNTAX'],
+ },
+ {
+ name: 'er malformed declaration still recovers',
+ diagramType: 'erDiagram',
+ source: `
+ erDiagram
+ CUSTOMER {
+ string id PK
+ }
+ entity ORDER {
+ CUSTOMER -> ORDER
+ `,
+ diagnosticIncludes: [
+ 'Invalid entity declaration at line',
+ 'Invalid erDiagram relation syntax at line',
+ ],
+ expectedDiagnosticCodes: ['MERMAID_SYNTAX'],
+ },
+ {
+ name: 'architecture implicit-node recovery stays editable',
+ diagramType: 'architecture',
+ source: `
+ architecture-beta
+ service api(server)[API]
+ api --> cache
+ `,
+ diagnosticIncludes: ['Recovered implicit service node "cache"'],
+ expectedDiagnosticCodes: ['MERMAID_RECOVERY'],
+ },
+ {
+ name: 'sequence malformed message still recovers',
+ diagramType: 'sequence',
+ source: `
+ sequenceDiagram
+ participant A
+ participant B
+ A->>B: Hello
+ A->>
+ `,
+ diagnosticIncludes: ['Invalid message at line'],
+ expectedDiagnosticCodes: ['MERMAID_SYNTAX'],
+ },
+ {
+ name: 'mindmap malformed wrapper still recovers',
+ diagramType: 'mindmap',
+ source: `
+ mindmap
+ Root
+ bad((Unclosed
+ Child
+ `,
+ diagnosticIncludes: ['Malformed mindmap wrapper syntax at line'],
+ expectedDiagnosticCodes: ['MERMAID_SYNTAX'],
+ },
+ {
+ name: 'journey invalid score still recovers',
+ diagramType: 'journey',
+ source: `
+ journey
+ title Checkout
+ section Happy
+ Search: User
+ Resolve issue: 5: Agent
+ `,
+ diagnosticIncludes: ['Invalid journey score at line'],
+ expectedDiagnosticCodes: ['MERMAID_SYNTAX'],
+ },
+];
+
+describe('Mermaid editable partial corpus', () => {
+ it('keeps malformed-but-recoverable fixtures in editable_partial with structured diagnostics', () => {
+ for (const corpusCase of PARTIAL_CORPUS) {
+ const result = parseMermaidByType(corpusCase.source);
+
+ expect(result.diagramType, corpusCase.name).toBe(corpusCase.diagramType);
+ expect(result.error, corpusCase.name).toBeUndefined();
+ expect(result.importState, corpusCase.name).toBe('editable_partial');
+ expect(result.nodes.length, corpusCase.name).toBeGreaterThan(0);
+ expect(result.structuredDiagnostics?.length ?? 0, corpusCase.name).toBeGreaterThan(0);
+ for (const expectedCode of corpusCase.expectedDiagnosticCodes ?? ['MERMAID_SYNTAX']) {
+ expect(
+ result.structuredDiagnostics?.some((diagnostic) => diagnostic.code === expectedCode),
+ `${corpusCase.name} should include structured diagnostic code "${expectedCode}"`
+ ).toBe(true);
+ }
+
+ corpusCase.diagnosticIncludes.forEach((expectedMessage) => {
+ expect(
+ result.diagnostics?.some((diagnostic) => diagnostic.includes(expectedMessage)),
+ `${corpusCase.name} should include diagnostic containing "${expectedMessage}"`
+ ).toBe(true);
+ });
+ }
+ });
+});
diff --git a/src/services/mermaid/importContracts.ts b/src/services/mermaid/importContracts.ts
new file mode 100644
index 00000000..128e613a
--- /dev/null
+++ b/src/services/mermaid/importContracts.ts
@@ -0,0 +1,109 @@
+import type { ParseDiagnostic } from '@/lib/openFlowDSLParser';
+import type { DiagramType } from '@/lib/types';
+import { normalizeParseDiagnostics } from './diagnosticFormatting';
+
+export type MermaidImportStatus =
+ | 'editable_full'
+ | 'editable_partial'
+ | 'invalid_source'
+ | 'unsupported_family'
+ | 'unsupported_construct';
+
+export interface MermaidImportDiagnostic extends ParseDiagnostic {
+ code: string;
+ severity: 'info' | 'warning' | 'error';
+ family?: DiagramType;
+ officialMermaidAccepted?: boolean;
+ editableImpact?: 'none' | 'partial' | 'blocked';
+}
+
+function inferDiagnosticCode(message: string): MermaidImportDiagnostic['code'] {
+ const normalized = message.toLowerCase();
+
+ if (normalized.includes('duplicate')) return 'MERMAID_IDENTITY';
+ if (normalized.includes('recovered implicit')) return 'MERMAID_RECOVERY';
+ if (
+ normalized.includes('invalid')
+ || normalized.includes('malformed')
+ || normalized.includes('unclosed')
+ || normalized.includes('unrecognized')
+ || normalized.includes('indentation jump')
+ || normalized.includes('odd indentation')
+ ) {
+ return 'MERMAID_SYNTAX';
+ }
+
+ return 'MERMAID_GENERAL';
+}
+
+function inferSeverity(
+ message: string,
+ parseBlocked: boolean
+): MermaidImportDiagnostic['severity'] {
+ const normalized = message.toLowerCase();
+
+ if (parseBlocked) return 'error';
+ if (normalized.includes('recovered implicit')) return 'warning';
+ if (normalized.includes('duplicate')) return 'warning';
+ if (normalized.includes('invalid') || normalized.includes('malformed')) return 'warning';
+ if (normalized.includes('unrecognized') || normalized.includes('unclosed')) return 'warning';
+ return 'info';
+}
+
+function inferEditableImpact(
+ message: string,
+ parseBlocked: boolean
+): MermaidImportDiagnostic['editableImpact'] {
+ if (parseBlocked) return 'blocked';
+
+ const normalized = message.toLowerCase();
+ if (
+ normalized.includes('invalid')
+ || normalized.includes('malformed')
+ || normalized.includes('unrecognized')
+ || normalized.includes('unclosed')
+ || normalized.includes('recovered implicit')
+ || normalized.includes('duplicate')
+ || normalized.includes('indentation jump')
+ || normalized.includes('odd indentation')
+ ) {
+ return 'partial';
+ }
+
+ return 'none';
+}
+
+export function normalizeMermaidImportDiagnostics(params: {
+ diagnostics: unknown;
+ family?: DiagramType;
+ parseBlocked?: boolean;
+ officialMermaidAccepted?: boolean;
+}): MermaidImportDiagnostic[] {
+ const normalized = normalizeParseDiagnostics(params.diagnostics);
+
+ return normalized.map((diagnostic) => ({
+ ...diagnostic,
+ code: inferDiagnosticCode(diagnostic.message),
+ severity: inferSeverity(diagnostic.message, Boolean(params.parseBlocked)),
+ family: params.family,
+ officialMermaidAccepted: params.officialMermaidAccepted,
+ editableImpact: inferEditableImpact(diagnostic.message, Boolean(params.parseBlocked)),
+ }));
+}
+
+export function determineMermaidImportStatus(params: {
+ hasHeader: boolean;
+ isSupportedFamily: boolean;
+ error?: string;
+ structuredDiagnostics?: MermaidImportDiagnostic[];
+}): MermaidImportStatus {
+ if (!params.hasHeader) return 'invalid_source';
+ if (!params.isSupportedFamily) return 'unsupported_family';
+ if (params.error) return 'unsupported_construct';
+
+ const hasDegradingDiagnostics = (params.structuredDiagnostics ?? []).some(
+ (diagnostic) => diagnostic.editableImpact === 'partial' || diagnostic.severity === 'warning'
+ );
+
+ return hasDegradingDiagnostics ? 'editable_partial' : 'editable_full';
+}
diff --git a/src/services/mermaid/importStatePresentation.test.ts b/src/services/mermaid/importStatePresentation.test.ts
new file mode 100644
index 00000000..c4c086cb
--- /dev/null
+++ b/src/services/mermaid/importStatePresentation.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from 'vitest';
+import {
+ appendMermaidImportGuidance,
+ getMermaidImportStateDetail,
+ getMermaidImportStateGuidance,
+ getMermaidImportStateLabel,
+ getMermaidImportToastMessage,
+ summarizeMermaidImport,
+} from './importStatePresentation';
+
+describe('importStatePresentation', () => {
+ it('describes partially editable Mermaid imports consistently', () => {
+ expect(getMermaidImportStateLabel('editable_partial')).toBe('Ready with warnings');
+ expect(
+ getMermaidImportStateDetail({
+ importState: 'editable_partial',
+ diagramType: 'sequence',
+ nodeCount: 4,
+ edgeCount: 3,
+ })
+ ).toBe('4 nodes, 3 edges, partial editability (advanced fragment fidelity)');
+ expect(
+ getMermaidImportToastMessage({
+ importState: 'editable_partial',
+ warningCount: 2,
+ })
+ ).toBe('Imported with warnings: partial editability (2 diagnostic warnings).');
+ });
+
+ it('summarizes clean Mermaid imports without fallback wording', () => {
+ expect(
+ summarizeMermaidImport({
+ diagramType: 'flowchart',
+ importState: 'editable_full',
+ nodeCount: 2,
+ edgeCount: 1,
+ })
+ ).toBe('Mermaid flowchart: Ready to apply (2 nodes, 1 edges)');
+ });
+
+ it('adds guidance for unsupported Mermaid families', () => {
+ expect(getMermaidImportStateGuidance('unsupported_family')).toContain('not editable yet');
+ expect(
+ appendMermaidImportGuidance({
+ message: 'Mermaid "gitGraph" is not supported yet in editable mode.',
+ importState: 'unsupported_family',
+ })
+ ).toContain('not editable yet');
+ });
+
+ it('uses family-specific guidance for partial imports when support matrix data exists', () => {
+ expect(getMermaidImportStateGuidance('editable_partial', 'classDiagram')).toContain(
+ 'generics and visibility richness'
+ );
+ expect(
+ appendMermaidImportGuidance({
+ message: 'Some class members could not be mapped cleanly.',
+ importState: 'unsupported_construct',
+ diagramType: 'classDiagram',
+ })
+ ).toContain('Current partial areas for Class Diagram include generics and visibility richness');
+ });
+});
diff --git a/src/services/mermaid/importStatePresentation.ts b/src/services/mermaid/importStatePresentation.ts
new file mode 100644
index 00000000..ff8ea8f2
--- /dev/null
+++ b/src/services/mermaid/importStatePresentation.ts
@@ -0,0 +1,127 @@
+import type { DiagramType } from '@/lib/types';
+import type { MermaidImportStatus } from './importContracts';
+import { getMermaidFamilySupportMatrixEntry } from './supportMatrix';
+
+function formatGraphSummary(nodeCount: number, edgeCount: number): string {
+ return `${nodeCount} nodes, ${edgeCount} edges`;
+}
+
+export function getMermaidImportStateLabel(
+ importState: MermaidImportStatus | undefined
+): string {
+ switch (importState) {
+ case 'editable_partial':
+ return 'Ready with warnings';
+ case 'unsupported_construct':
+ return 'Unsupported Mermaid construct';
+ case 'unsupported_family':
+ return 'Unsupported Mermaid family';
+ case 'invalid_source':
+ return 'Needs fixes';
+ case 'editable_full':
+ default:
+ return 'Ready to apply';
+ }
+}
+
+export function getMermaidImportStateDetail(params: {
+ importState: MermaidImportStatus | undefined;
+ diagramType?: DiagramType;
+ nodeCount: number;
+ edgeCount: number;
+}): string {
+ const graphSummary = formatGraphSummary(params.nodeCount, params.edgeCount);
+ const supportEntry = params.diagramType
+ ? getMermaidFamilySupportMatrixEntry(params.diagramType)
+ : undefined;
+
+ switch (params.importState) {
+ case 'editable_partial':
+ return supportEntry?.partialConstructs.length
+ ? `${graphSummary}, partial editability (${supportEntry.partialConstructs[0]})`
+ : `${graphSummary}, partial editability`;
+ case 'unsupported_construct':
+ return supportEntry?.partialConstructs.length
+ ? `${graphSummary}, unsupported construct fallback required (${supportEntry.partialConstructs[0]})`
+ : `${graphSummary}, unsupported construct fallback required`;
+ case 'unsupported_family':
+ return `${graphSummary}, unsupported family fallback required`;
+ case 'invalid_source':
+ return 'Mermaid source needs fixes before it can be applied.';
+ case 'editable_full':
+ default:
+ return graphSummary;
+ }
+}
+
+export function getMermaidImportToastMessage(params: {
+ importState: MermaidImportStatus | undefined;
+ warningCount: number;
+}): string | null {
+ if (params.importState === 'editable_partial') {
+ return `Imported with warnings: partial editability (${params.warningCount} diagnostic warning${params.warningCount === 1 ? '' : 's'}).`;
+ }
+
+ if (params.warningCount > 0) {
+ return `Imported with ${params.warningCount} diagnostic warning${params.warningCount === 1 ? '' : 's'}.`;
+ }
+
+ return null;
+}
+
+export function getMermaidImportStateGuidance(
+ importState: MermaidImportStatus | undefined,
+ diagramType?: DiagramType
+): string | undefined {
+ const supportEntry = diagramType
+ ? getMermaidFamilySupportMatrixEntry(diagramType)
+ : undefined;
+
+ switch (importState) {
+ case 'editable_partial':
+ return supportEntry?.partialConstructs.length
+ ? `Review the diagnostics after apply; ${supportEntry.label} still has partial support around ${supportEntry.partialConstructs.slice(0, 2).join(' and ')}.`
+ : 'Review the diagnostics after apply; some Mermaid constructs may not stay fully editable.';
+ case 'unsupported_construct':
+ return supportEntry?.partialConstructs.length
+ ? `Keep working in Mermaid code or simplify the unsupported construct before applying. Current partial areas for ${supportEntry.label} include ${supportEntry.partialConstructs.slice(0, 2).join(' and ')}.`
+ : 'Keep working in Mermaid code or simplify the unsupported construct before applying.';
+ case 'unsupported_family':
+ return 'This Mermaid family is not editable yet. Keep working in Mermaid code or switch to a supported family.';
+ case 'invalid_source':
+ return 'Fix the Mermaid syntax or header and retry.';
+ default:
+ return undefined;
+ }
+}
+
+export function appendMermaidImportGuidance(params: {
+ message: string;
+ importState: MermaidImportStatus | undefined;
+ diagramType?: DiagramType;
+}): string {
+ const guidance = getMermaidImportStateGuidance(params.importState, params.diagramType);
+ if (!guidance || params.message.includes(guidance)) {
+ return params.message;
+ }
+
+ return `${params.message} ${guidance}`;
+}
+
+export function summarizeMermaidImport(params: {
+ diagramType?: string;
+ importState?: MermaidImportStatus;
+ nodeCount: number;
+ edgeCount: number;
+}): string {
+ const typeLabel = params.diagramType ?? 'diagram';
+ const label = getMermaidImportStateLabel(params.importState);
+ const detail = getMermaidImportStateDetail({
+ importState: params.importState,
+ diagramType: params.diagramType as DiagramType | undefined,
+ nodeCount: params.nodeCount,
+ edgeCount: params.edgeCount,
+ });
+
+ return `Mermaid ${typeLabel}: ${label} (${detail})`;
+}
diff --git a/src/services/mermaid/mermaidLayoutCorpus.test.ts b/src/services/mermaid/mermaidLayoutCorpus.test.ts
new file mode 100644
index 00000000..037efbd2
--- /dev/null
+++ b/src/services/mermaid/mermaidLayoutCorpus.test.ts
@@ -0,0 +1,220 @@
+import { describe, expect, it } from 'vitest';
+import { composeDiagramForDisplay } from '@/services/composeDiagramForDisplay';
+import { parseMermaidByType } from './parseMermaidByType';
+import type { MermaidImportStatus } from './importContracts';
+import { MERMAID_COMPAT_FIXTURES } from '../../../scripts/mermaid-compat-fixtures.mjs';
+
+interface MermaidLayoutFixture {
+ name: string;
+ source: string;
+ expectedImportState: MermaidImportStatus;
+ layoutAssertions?: {
+ maxBoundingWidth?: number;
+ maxBoundingHeight?: number;
+ requireUniquePositions?: boolean;
+ minSections?: number;
+ minParticipants?: number;
+ requireSequenceLaneAlignment?: boolean;
+ requireNotesBelowParticipants?: boolean;
+ orderedLabelsLeftToRight?: string[];
+ orderedLabelsTopToBottom?: string[];
+ sameRowLabels?: string[];
+ sameColumnLabels?: string[];
+ verticalFlowLabels?: string[];
+ horizontalFlowLabels?: string[];
+ };
+}
+
+function getBounds(nodes: Array<{ position: { x: number; y: number } }>): {
+ width: number;
+ height: number;
+} {
+ if (nodes.length === 0) {
+ return { width: 0, height: 0 };
+ }
+
+ const xs = nodes.map((node) => node.position.x);
+ const ys = nodes.map((node) => node.position.y);
+ return {
+ width: Math.max(...xs) - Math.min(...xs),
+ height: Math.max(...ys) - Math.min(...ys),
+ };
+}
+
+function findNodeByLabel(
+ nodes: Array<{ data?: { label?: unknown }; position: { x: number; y: number } }>,
+ label: string
+): { position: { x: number; y: number } } | undefined {
+ return nodes.find((node) => String(node.data?.label ?? '').includes(label));
+}
+
+function expectLabelsPresent(
+ nodes: Array<{ data?: { label?: unknown }; position: { x: number; y: number } }>,
+ labels: string[],
+ fixtureName: string,
+ direction: 'row' | 'column'
+): Array<{ position: { x: number; y: number } }> {
+ return labels.map((label) => {
+ const node = findNodeByLabel(nodes, label);
+ expect(
+ node,
+ `${fixtureName} should include label "${label}" for ${direction} alignment`
+ ).toBeDefined();
+ return node!;
+ });
+}
+
+describe('Mermaid layout corpus invariants', () => {
+ it('keeps representative imported diagrams compact and structurally clear', async () => {
+ const fixtures = (MERMAID_COMPAT_FIXTURES as MermaidLayoutFixture[]).filter(
+ (fixture) => fixture.layoutAssertions
+ );
+
+ for (const fixture of fixtures) {
+ const parsed = parseMermaidByType(fixture.source);
+ expect(parsed.importState, fixture.name).toBe(fixture.expectedImportState);
+ expect(parsed.error, fixture.name).toBeUndefined();
+
+ const layouted = await composeDiagramForDisplay(parsed.nodes, parsed.edges, {
+ direction: parsed.direction ?? 'TB',
+ spacing: 'compact',
+ diagramType: parsed.diagramType,
+ source: 'import',
+ });
+
+ const visibleNodes = layouted.nodes.filter((node) => !node.hidden);
+ const bounds = getBounds(visibleNodes);
+ const assertions = fixture.layoutAssertions!;
+
+ if (typeof assertions.maxBoundingWidth === 'number') {
+ expect(bounds.width, fixture.name).toBeLessThanOrEqual(assertions.maxBoundingWidth);
+ }
+ if (typeof assertions.maxBoundingHeight === 'number') {
+ expect(bounds.height, fixture.name).toBeLessThanOrEqual(assertions.maxBoundingHeight);
+ }
+ if (assertions.requireUniquePositions) {
+ const uniquePositions = new Set(
+ visibleNodes.map((node) => `${Math.round(node.position.x)}:${Math.round(node.position.y)}`)
+ );
+ expect(uniquePositions.size, fixture.name).toBeGreaterThan(1);
+ }
+ if (typeof assertions.minSections === 'number') {
+ expect(
+ layouted.nodes.filter((node) => node.type === 'section').length,
+ fixture.name
+ ).toBeGreaterThanOrEqual(assertions.minSections);
+ }
+ if (typeof assertions.minParticipants === 'number') {
+ const participants = layouted.nodes.filter((node) => node.type === 'sequence_participant');
+ expect(participants.length, fixture.name).toBeGreaterThanOrEqual(assertions.minParticipants);
+
+ if (assertions.requireSequenceLaneAlignment) {
+ const yValues = new Set(participants.map((node) => Math.round(node.position.y)));
+ expect(yValues.size, fixture.name).toBeLessThanOrEqual(2);
+ const xValues = participants.map((node) => node.position.x);
+ expect([...xValues].sort((a, b) => a - b), fixture.name).toEqual(xValues);
+ }
+ }
+ if (assertions.requireNotesBelowParticipants) {
+ const participants = layouted.nodes.filter((node) => node.type === 'sequence_participant');
+ const notes = layouted.nodes.filter((node) => node.type === 'sequence_note');
+ const participantBottom = Math.max(...participants.map((node) => node.position.y));
+ expect(notes.length, fixture.name).toBeGreaterThan(0);
+ expect(notes.every((node) => node.position.y >= participantBottom), fixture.name).toBe(true);
+ }
+ if (Array.isArray(assertions.orderedLabelsLeftToRight)) {
+ const orderedNodes = assertions.orderedLabelsLeftToRight.map((label) => {
+ const node = findNodeByLabel(visibleNodes, label);
+ expect(node, `${fixture.name} should include label "${label}" for left-to-right order`).toBeDefined();
+ return node!;
+ });
+ for (let index = 1; index < orderedNodes.length; index += 1) {
+ expect(
+ orderedNodes[index - 1].position.x,
+ `${fixture.name} should keep "${assertions.orderedLabelsLeftToRight[index - 1]}" left of "${assertions.orderedLabelsLeftToRight[index]}"`
+ ).toBeLessThanOrEqual(orderedNodes[index].position.x);
+ }
+ }
+ if (Array.isArray(assertions.orderedLabelsTopToBottom)) {
+ const orderedNodes = expectLabelsPresent(
+ visibleNodes,
+ assertions.orderedLabelsTopToBottom,
+ fixture.name,
+ 'column'
+ );
+ for (let index = 1; index < orderedNodes.length; index += 1) {
+ expect(
+ orderedNodes[index - 1].position.y,
+ `${fixture.name} should keep "${assertions.orderedLabelsTopToBottom[index - 1]}" above "${assertions.orderedLabelsTopToBottom[index]}"`
+ ).toBeLessThanOrEqual(orderedNodes[index].position.y);
+ }
+ }
+ if (Array.isArray(assertions.sameRowLabels)) {
+ const alignedNodes = expectLabelsPresent(
+ visibleNodes,
+ assertions.sameRowLabels,
+ fixture.name,
+ 'row'
+ );
+ const referenceY = Math.round(alignedNodes[0].position.y);
+ expect(
+ alignedNodes.every((node) => Math.abs(Math.round(node.position.y) - referenceY) <= 8),
+ `${fixture.name} should keep ${assertions.sameRowLabels.join(', ')} on the same row`
+ ).toBe(true);
+ }
+ if (Array.isArray(assertions.sameColumnLabels)) {
+ const alignedNodes = expectLabelsPresent(
+ visibleNodes,
+ assertions.sameColumnLabels,
+ fixture.name,
+ 'column'
+ );
+ const referenceX = Math.round(alignedNodes[0].position.x);
+ expect(
+ alignedNodes.every((node) => Math.abs(Math.round(node.position.x) - referenceX) <= 8),
+ `${fixture.name} should keep ${assertions.sameColumnLabels.join(', ')} in the same column`
+ ).toBe(true);
+ }
+ if (Array.isArray(assertions.verticalFlowLabels)) {
+ const orderedNodes = expectLabelsPresent(
+ visibleNodes,
+ assertions.verticalFlowLabels,
+ fixture.name,
+ 'column'
+ );
+ for (let index = 1; index < orderedNodes.length; index += 1) {
+ const deltaX = Math.abs(orderedNodes[index].position.x - orderedNodes[index - 1].position.x);
+ const deltaY = orderedNodes[index].position.y - orderedNodes[index - 1].position.y;
+ expect(
+ deltaY,
+ `${fixture.name} should keep "${assertions.verticalFlowLabels[index]}" below "${assertions.verticalFlowLabels[index - 1]}"`
+ ).toBeGreaterThanOrEqual(0);
+ expect(
+ Math.abs(deltaY),
+ `${fixture.name} should move more vertically than horizontally between "${assertions.verticalFlowLabels[index - 1]}" and "${assertions.verticalFlowLabels[index]}"`
+ ).toBeGreaterThanOrEqual(deltaX);
+ }
+ }
+ if (Array.isArray(assertions.horizontalFlowLabels)) {
+ const orderedNodes = expectLabelsPresent(
+ visibleNodes,
+ assertions.horizontalFlowLabels,
+ fixture.name,
+ 'row'
+ );
+ for (let index = 1; index < orderedNodes.length; index += 1) {
+ const deltaX = orderedNodes[index].position.x - orderedNodes[index - 1].position.x;
+ const deltaY = Math.abs(orderedNodes[index].position.y - orderedNodes[index - 1].position.y);
+ expect(
+ deltaX,
+ `${fixture.name} should keep "${assertions.horizontalFlowLabels[index]}" right of "${assertions.horizontalFlowLabels[index - 1]}"`
+ ).toBeGreaterThanOrEqual(0);
+ expect(
+ Math.abs(deltaX),
+ `${fixture.name} should move more horizontally than vertically between "${assertions.horizontalFlowLabels[index - 1]}" and "${assertions.horizontalFlowLabels[index]}"`
+ ).toBeGreaterThanOrEqual(deltaY);
+ }
+ }
+ }
+ });
+});
diff --git a/src/services/mermaid/officialMermaidValidation.test.ts b/src/services/mermaid/officialMermaidValidation.test.ts
new file mode 100644
index 00000000..9da40166
--- /dev/null
+++ b/src/services/mermaid/officialMermaidValidation.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from 'vitest';
+import {
+ detectMermaidWithOfficialParser,
+ getOfficialMermaidDiagnostics,
+ isOfficialMermaidValidationBlocking,
+ validateMermaidWithOfficialParser,
+} from './officialMermaidValidation';
+
+describe('officialMermaidValidation', () => {
+ it('uses official Mermaid type detection for supported families', () => {
+ const result = detectMermaidWithOfficialParser('flowchart TD\nA-->B');
+
+ expect(result.isAvailable).toBe(true);
+ expect(result.rawType).toBe('flowchart');
+ expect(result.detectedType).toBe('flowchart');
+ expect(result.validationMode).toBe('detection_only');
+ });
+
+ it('detects unsupported official Mermaid families without pretending they are missing headers', () => {
+ const result = detectMermaidWithOfficialParser('gitGraph\ncommit id: "A"');
+
+ expect(result.isValid).toBe(true);
+ expect(result.rawType).toBe('gitGraph');
+ expect(result.detectedType).toBeUndefined();
+ });
+
+ it('returns official parse diagnostics for invalid Mermaid syntax', async () => {
+ const result = await validateMermaidWithOfficialParser('flowchart TD\nA -->');
+
+ if (result.validationMode === 'full') {
+ expect(result.isValid).toBe(false);
+ expect(result.diagnostics[0]?.code).toBe('MERMAID_OFFICIAL_PARSE');
+ expect(result.diagnostics[0]?.message).toContain('Parse error');
+ return;
+ }
+
+ expect(result.validationMode).toBe('detection_only');
+ expect(result.diagnostics[0]?.message).toContain('browser-like DOM runtime');
+ });
+
+ it('maps official architecture syntax detection to the architecture family', () => {
+ const result = detectMermaidWithOfficialParser(
+ 'architecture-beta\nservice api(server)[API]\nservice db(database)[Database]\napi:R --> L:db'
+ );
+
+ expect(result.rawType).toBe('architecture-beta');
+ expect(result.detectedType).toBe('architecture');
+ });
+
+ it('treats detection-only official validation warnings as non-blocking diagnostics', async () => {
+ const result = await validateMermaidWithOfficialParser('flowchart TD\nA[Start] --> B[End]');
+
+ if (result.validationMode === 'full') {
+ expect(isOfficialMermaidValidationBlocking(result)).toBe(false);
+ expect(getOfficialMermaidDiagnostics(result)).toEqual([]);
+ return;
+ }
+
+ expect(isOfficialMermaidValidationBlocking(result)).toBe(false);
+ expect(getOfficialMermaidDiagnostics(result)).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ message: expect.stringContaining('browser-like DOM runtime'),
+ }),
+ ])
+ );
+ });
+});
diff --git a/src/services/mermaid/officialMermaidValidation.ts b/src/services/mermaid/officialMermaidValidation.ts
new file mode 100644
index 00000000..f01e1a9c
--- /dev/null
+++ b/src/services/mermaid/officialMermaidValidation.ts
@@ -0,0 +1,199 @@
+import type { ParseDiagnostic } from '@/lib/openFlowDSLParser';
+import type { DiagramType } from '@/lib/types';
+import type { MermaidImportDiagnostic } from './importContracts';
+import { extractMermaidDiagramHeader } from './detectDiagramType';
+
+type ValidationMode = 'detection_only' | 'full';
+
+export interface OfficialMermaidValidationResult {
+ isAvailable: boolean;
+ isValid: boolean;
+ detectedType?: DiagramType;
+ rawType?: string;
+ diagnostics: MermaidImportDiagnostic[];
+ validationMode: ValidationMode;
+}
+
+let initialized = false;
+let mermaidRuntimePromise: Promise | null = null;
+
+interface OfficialMermaidRuntime {
+ initialize: (config: {
+ startOnLoad: boolean;
+ securityLevel: 'loose';
+ suppressErrorRendering: boolean;
+ }) => void;
+ parse: (
+ input: string,
+ options: { suppressErrors: boolean }
+ ) => Promise;
+}
+
+function canLoadOfficialRuntime(): boolean {
+ return !import.meta.env.PROD && typeof window !== 'undefined' && typeof document !== 'undefined';
+}
+
+function canRunFullOfficialValidation(): boolean {
+ return typeof window !== 'undefined' && typeof document !== 'undefined';
+}
+
+async function getOfficialMermaidRuntime(): Promise {
+ if (!canLoadOfficialRuntime()) {
+ return null;
+ }
+
+ if (!mermaidRuntimePromise) {
+ mermaidRuntimePromise = import('mermaid')
+ .then((module) => {
+ const runtime = module.default as OfficialMermaidRuntime;
+ if (!initialized) {
+ runtime.initialize({
+ startOnLoad: false,
+ securityLevel: 'loose',
+ suppressErrorRendering: true,
+ });
+ initialized = true;
+ }
+ return runtime;
+ })
+ .catch(() => null);
+ }
+
+ return mermaidRuntimePromise;
+}
+
+function mapOfficialType(rawType: string | undefined): DiagramType | undefined {
+ if (!rawType) return undefined;
+
+ const normalized = rawType.trim();
+ if (normalized === 'flowchart-v2' || normalized === 'flowchart' || normalized === 'graph') {
+ return 'flowchart';
+ }
+ if (normalized === 'stateDiagram' || normalized === 'stateDiagram-v2') {
+ return 'stateDiagram';
+ }
+ if (normalized === 'classDiagram' || normalized === 'class') return 'classDiagram';
+ if (normalized === 'erDiagram' || normalized === 'er') return 'erDiagram';
+ if (normalized === 'mindmap') return 'mindmap';
+ if (normalized === 'journey') return 'journey';
+ if (normalized === 'architecture' || normalized === 'architecture-beta') return 'architecture';
+ if (normalized === 'sequenceDiagram' || normalized === 'sequence') return 'sequence';
+ return undefined;
+}
+
+function toDiagnostic(
+ message: string,
+ mode: ValidationMode,
+ severity: MermaidImportDiagnostic['severity'] = 'error'
+): MermaidImportDiagnostic {
+ return {
+ code: mode === 'full' ? 'MERMAID_OFFICIAL_PARSE' : 'MERMAID_OFFICIAL_ENV',
+ severity,
+ message,
+ officialMermaidAccepted: mode === 'full' ? false : undefined,
+ editableImpact: severity === 'error' ? 'blocked' : 'none',
+ };
+}
+
+function isEnvironmentLimitationError(message: string): boolean {
+ return message.includes('DOMPurify') || message.includes('document is not defined');
+}
+
+export function isOfficialMermaidValidationBlocking(
+ result: OfficialMermaidValidationResult
+): boolean {
+ return result.validationMode === 'full' && !result.isValid;
+}
+
+export function getOfficialMermaidDiagnostics(
+ result: OfficialMermaidValidationResult
+): ParseDiagnostic[] {
+ return result.diagnostics.map((diagnostic) => ({
+ message: diagnostic.message,
+ line: diagnostic.line,
+ snippet: diagnostic.snippet,
+ hint: diagnostic.hint,
+ }));
+}
+
+export function getOfficialMermaidErrorMessage(
+ result: OfficialMermaidValidationResult
+): string | undefined {
+ return result.diagnostics[0]?.message;
+}
+
+export function detectMermaidWithOfficialParser(input: string): OfficialMermaidValidationResult {
+ const header = extractMermaidDiagramHeader(input);
+ return {
+ isAvailable: true,
+ isValid: Boolean(header.rawType),
+ rawType: header.rawType,
+ detectedType: header.diagramType ?? mapOfficialType(header.rawType),
+ diagnostics: [],
+ validationMode: 'detection_only',
+ };
+}
+
+export async function validateMermaidWithOfficialParser(
+ input: string
+): Promise {
+ const detection = detectMermaidWithOfficialParser(input);
+
+ if (!detection.rawType) {
+ return {
+ ...detection,
+ isValid: false,
+ validationMode: 'full',
+ diagnostics: [toDiagnostic('Official Mermaid could not detect a diagram type.', 'full')],
+ };
+ }
+
+ const mermaid = await getOfficialMermaidRuntime();
+ if (!mermaid || !canRunFullOfficialValidation()) {
+ return {
+ ...detection,
+ validationMode: 'detection_only',
+ diagnostics: canLoadOfficialRuntime()
+ ? [
+ toDiagnostic(
+ 'Official Mermaid full validation requires a browser-like DOM runtime. Falling back to type detection only.',
+ 'detection_only',
+ 'warning'
+ ),
+ ]
+ : [],
+ };
+ }
+
+ try {
+ const parsed = await mermaid.parse(input, { suppressErrors: false });
+ return {
+ ...detection,
+ isValid: Boolean(parsed),
+ validationMode: 'full',
+ diagnostics: [],
+ };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ if (isEnvironmentLimitationError(message)) {
+ return {
+ ...detection,
+ validationMode: 'detection_only',
+ diagnostics: [
+ toDiagnostic(
+ `Official Mermaid validation fell back to type detection only in this environment: ${message}`,
+ 'detection_only',
+ 'warning'
+ ),
+ ],
+ };
+ }
+
+ return {
+ ...detection,
+ isValid: false,
+ validationMode: 'full',
+ diagnostics: [toDiagnostic(message, 'full')],
+ };
+ }
+}
diff --git a/src/services/mermaid/parseMermaidByType.test.ts b/src/services/mermaid/parseMermaidByType.test.ts
index 01ce8d4b..a7a0557e 100644
--- a/src/services/mermaid/parseMermaidByType.test.ts
+++ b/src/services/mermaid/parseMermaidByType.test.ts
@@ -10,6 +10,7 @@ describe('parseMermaidByType', () => {
expect(result.error).toBeUndefined();
expect(result.diagramType).toBe('flowchart');
+ expect(result.importState).toBe('editable_full');
expect(result.nodes).toHaveLength(2);
expect(result.edges).toHaveLength(1);
});
@@ -25,6 +26,52 @@ describe('parseMermaidByType', () => {
expect(result.direction).toBe('LR');
});
+ it('returns flowchart diagnostics for malformed structure without failing parse', () => {
+ const result = parseMermaidByType(`
+ flowchart TD
+ subgraph
+ A --> B
+ stray words
+ end
+ end
+ `);
+
+ expect(result.diagramType).toBe('flowchart');
+ expect(result.error).toBeUndefined();
+ expect(result.nodes.length).toBeGreaterThan(0);
+ expect(result.importState).toBe('editable_partial');
+ expect(result.diagnostics?.some((message) =>
+ message.includes('Invalid flowchart subgraph declaration at line')
+ )).toBe(true);
+ expect(result.diagnostics?.some((message) =>
+ message.includes('Unexpected flowchart block closer at line')
+ )).toBe(true);
+ expect(result.structuredDiagnostics?.some((diagnostic) => diagnostic.code === 'MERMAID_SYNTAX')).toBe(true);
+ });
+
+ it('parses modern flowchart ids and class assignment directives through the dispatcher', () => {
+ const result = parseMermaidByType(`
+ flowchart TD
+ api.gateway@{ shape: rect, label: "API Gateway" } --> db.primary[(Primary DB)]
+ classDef selected fill:#dff,stroke:#08c,color:#024
+ class api.gateway,db.primary selected
+ `);
+
+ expect(result.error).toBeUndefined();
+ expect(result.diagramType).toBe('flowchart');
+ expect(result.importState).toBe('editable_full');
+ expect(result.nodes.find((node) => node.id === 'api.gateway')?.data.label).toBe('API Gateway');
+ expect(result.nodes.find((node) => node.id === 'api.gateway')?.style).toMatchObject({
+ backgroundColor: '#dff',
+ borderColor: '#08c',
+ color: '#024',
+ });
+ expect(result.edges[0]).toMatchObject({
+ source: 'api.gateway',
+ target: 'db.primary',
+ });
+ });
+
it('parses supported state diagram families', () => {
const result = parseMermaidByType(`
stateDiagram-v2
@@ -37,6 +84,23 @@ describe('parseMermaidByType', () => {
expect(result.nodes.length).toBeGreaterThan(0);
});
+ it('keeps standalone composite state declarations parented through the dispatcher', () => {
+ const result = parseMermaidByType(`
+ stateDiagram-v2
+ state Working {
+ state Busy
+ state Idle
+ Busy --> Idle
+ }
+ Idle --> [*]
+ `);
+
+ expect(result.error).toBeUndefined();
+ expect(result.diagramType).toBe('stateDiagram');
+ expect(result.nodes.find((node) => node.id === 'Busy')?.parentId).toBe('Working');
+ expect(result.nodes.find((node) => node.id === 'Idle')?.parentId).toBe('Working');
+ });
+
it('parses classDiagram through plugin dispatcher', () => {
const result = parseMermaidByType(`
classDiagram
@@ -67,8 +131,16 @@ describe('parseMermaidByType', () => {
expect(result.diagramType).toBe('classDiagram');
expect(result.error).toBeUndefined();
expect(result.nodes.length).toBeGreaterThan(0);
- expect(result.diagnostics?.some((message) => message.includes('Invalid class declaration at line'))).toBe(true);
- expect(result.diagnostics?.some((message) => message.includes('Invalid class relation syntax at line'))).toBe(true);
+ expect(
+ result.diagnostics?.some((message) => message.includes('Invalid class declaration at line'))
+ ).toBe(true);
+ expect(result.importState).toBe('editable_partial');
+ expect(result.structuredDiagnostics?.some((diagnostic) => diagnostic.code === 'MERMAID_SYNTAX')).toBe(true);
+ expect(
+ result.diagnostics?.some((message) =>
+ message.includes('Invalid class relation syntax at line')
+ )
+ ).toBe(true);
});
it('parses erDiagram through plugin dispatcher', () => {
@@ -103,8 +175,14 @@ describe('parseMermaidByType', () => {
expect(result.diagramType).toBe('erDiagram');
expect(result.error).toBeUndefined();
expect(result.nodes.length).toBeGreaterThan(0);
- expect(result.diagnostics?.some((message) => message.includes('Invalid entity declaration at line'))).toBe(true);
- expect(result.diagnostics?.some((message) => message.includes('Invalid erDiagram relation syntax at line'))).toBe(true);
+ expect(
+ result.diagnostics?.some((message) => message.includes('Invalid entity declaration at line'))
+ ).toBe(true);
+ expect(
+ result.diagnostics?.some((message) =>
+ message.includes('Invalid erDiagram relation syntax at line')
+ )
+ ).toBe(true);
});
it('parses mindmap through plugin dispatcher', () => {
@@ -122,6 +200,26 @@ describe('parseMermaidByType', () => {
expect(result.nodes.every((node) => node.type === 'mindmap')).toBe(true);
});
+ it('keeps dotted wrapped mindmap aliases in editable_full when no diagnostics are present', () => {
+ const result = parseMermaidByType(`
+ mindmap
+ platform.root((Root))
+ platform.api[[Child A]]
+ platform.branch(Child B)
+ `);
+
+ expect(result.diagramType).toBe('mindmap');
+ expect(result.error).toBeUndefined();
+ expect(result.structuredDiagnostics).toEqual([]);
+ expect(result.importState).toBe('editable_full');
+ expect(result.nodes.find((node) => node.data.label === 'Root')?.data.mindmapAlias).toBe(
+ 'platform.root'
+ );
+ expect(
+ result.nodes.find((node) => node.data.label === 'Child A')?.data.mindmapAlias
+ ).toBe('platform.api');
+ });
+
it('parses journey through plugin dispatcher', () => {
const result = parseMermaidByType(`
journey
@@ -137,7 +235,7 @@ describe('parseMermaidByType', () => {
expect(result.nodes.every((node) => node.type === 'journey')).toBe(true);
});
- it('returns journey diagnostics for malformed section and invalid step syntax', () => {
+ it('returns journey diagnostics for malformed section and malformed score-like steps', () => {
const result = parseMermaidByType(`
journey
section
@@ -148,8 +246,14 @@ describe('parseMermaidByType', () => {
expect(result.diagramType).toBe('journey');
expect(result.error).toBeUndefined();
expect(result.nodes.length).toBeGreaterThan(0);
- expect(result.diagnostics?.some((message) => message.includes('Invalid journey section syntax at line'))).toBe(true);
- expect(result.diagnostics?.some((message) => message.includes('Invalid journey step syntax at line'))).toBe(true);
+ expect(
+ result.diagnostics?.some((message) =>
+ message.includes('Invalid journey section syntax at line')
+ )
+ ).toBe(true);
+ expect(
+ result.diagnostics?.some((message) => message.includes('Invalid journey score at line'))
+ ).toBe(true);
});
it('returns mindmap diagnostics for malformed indentation/wrapper lines', () => {
@@ -164,8 +268,29 @@ describe('parseMermaidByType', () => {
expect(result.diagramType).toBe('mindmap');
expect(result.error).toBeUndefined();
expect(result.nodes.length).toBeGreaterThan(0);
- expect(result.diagnostics?.some((message) => message.includes('Mindmap indentation jump at line'))).toBe(true);
- expect(result.diagnostics?.some((message) => message.includes('Malformed mindmap wrapper syntax at line'))).toBe(true);
+ expect(
+ result.diagnostics?.some((message) => message.includes('Mindmap indentation jump at line'))
+ ).toBe(true);
+ expect(
+ result.diagnostics?.some((message) =>
+ message.includes('Malformed mindmap wrapper syntax at line')
+ )
+ ).toBe(true);
+ });
+
+ it('parses sequenceDiagram through plugin dispatcher', () => {
+ const result = parseMermaidByType(`
+ sequenceDiagram
+ participant Alice
+ participant Bob
+ Alice->>Bob: Hello
+ Bob-->>Alice: Hi
+ `);
+
+ expect(result.diagramType).toBe('sequence');
+ expect(result.error).toBeUndefined();
+ expect(result.nodes.length).toBeGreaterThan(0);
+ expect(result.edges.length).toBeGreaterThan(0);
});
it('parses architecture through plugin dispatcher', () => {
@@ -187,32 +312,115 @@ describe('parseMermaidByType', () => {
});
it('rejects architecture recovery diagnostics in strict mode', () => {
- const result = parseMermaidByType(`
+ const result = parseMermaidByType(
+ `
architecture-beta
service api(server)[API]
api --> cache
- `, { architectureStrictMode: true });
+ `,
+ { architectureStrictMode: true }
+ );
expect(result.diagramType).toBe('architecture');
expect(result.error).toContain('strict mode rejected');
expect(result.nodes).toHaveLength(0);
expect(result.edges).toHaveLength(0);
- expect(result.diagnostics?.some((d) => d.includes('Recovered implicit service node "cache"'))).toBe(true);
+ expect(
+ result.diagnostics?.some((d) => d.includes('Recovered implicit service node "cache"'))
+ ).toBe(true);
});
- it('returns explicit unsupported error for non-supported families', () => {
+ it('returns missing-header error for unsupported diagram types like gitGraph', () => {
const result = parseMermaidByType(`
gitGraph
commit id: "A"
commit id: "B"
`);
- expect(result.diagramType).toBe('gitGraph');
- expect(result.error).toContain('not supported yet in editable mode');
+ expect(result.error).toContain('gitGraph');
+ expect(result.importState).toBe('unsupported_family');
expect(result.nodes).toHaveLength(0);
expect(result.edges).toHaveLength(0);
});
+ it('classifies missing chart headers as invalid source', () => {
+ const result = parseMermaidByType('A --> B');
+
+ expect(result.error).toContain('Missing chart type declaration');
+ expect(result.importState).toBe('invalid_source');
+ expect(result.structuredDiagnostics?.length).toBeGreaterThan(0);
+ expect(result.structuredDiagnostics?.[0]?.severity).toBe('error');
+ });
+
+ it('parses semicolon-terminated node declarations correctly', () => {
+ const result = parseMermaidByType(`
+ graph TB
+ A[Start] ==> B{Is it?};
+ B -->|Yes| C[OK];
+ C --> D[Rethink];
+ D -.-> B;
+ B ---->|No| E[End];
+ `);
+
+ expect(result.error).toBeUndefined();
+ expect(result.nodes).toHaveLength(5);
+ expect(result.edges).toHaveLength(5);
+
+ const b = result.nodes.find((n) => n.id === 'B');
+ expect(b?.data.label).toBe('Is it?');
+ expect(b?.data.shape).toBe('diamond');
+
+ const c = result.nodes.find((n) => n.id === 'C');
+ expect(c?.data.label).toBe('OK');
+
+ expect(result.edges.some((e) => e.source === 'B' && e.target === 'E')).toBe(true);
+ expect(result.edges.some((e) => e.source === 'D' && e.target === 'B')).toBe(true);
+ });
+
+ it('normalizes extended arrows (----> and ====>) to standard forms', () => {
+ const result = parseMermaidByType(`
+ flowchart TD
+ A ---->|No| B
+ C ====> D
+ `);
+
+ expect(result.error).toBeUndefined();
+ expect(result.edges).toHaveLength(2);
+ expect(result.edges[0].source).toBe('A');
+ expect(result.edges[0].target).toBe('B');
+ expect(result.edges[1].source).toBe('C');
+ expect(result.edges[1].target).toBe('D');
+ });
+
+ it('parses chained edges on a single line', () => {
+ const result = parseMermaidByType(`
+ flowchart LR
+ A[Client] --> B[API] --> C[(DB)]
+ `);
+
+ expect(result.error).toBeUndefined();
+ expect(result.nodes).toHaveLength(3);
+ expect(result.edges).toHaveLength(2);
+ expect(result.edges.map((edge) => `${edge.source}->${edge.target}`)).toEqual([
+ 'A->B',
+ 'B->C',
+ ]);
+ });
+
+ it('keeps arrow-like text inside labels from corrupting edge parsing', () => {
+ const result = parseMermaidByType(`
+ flowchart TD
+ A["A --> B?"] -->|"status --> ok"| B["Done"]
+ `);
+
+ expect(result.error).toBeUndefined();
+ expect(result.nodes.find((node) => node.id === 'A')?.data.label).toBe('A --> B?');
+ expect(result.edges).toHaveLength(1);
+ expect(result.edges[0].label).toBe('status --> ok');
+ expect(result.edges[0].source).toBe('A');
+ expect(result.edges[0].target).toBe('B');
+ });
+
it('returns missing-header error when no family header exists', () => {
const result = parseMermaidByType('A --> B');
diff --git a/src/services/mermaid/parseMermaidByType.ts b/src/services/mermaid/parseMermaidByType.ts
index 3ece8892..ed3d75e0 100644
--- a/src/services/mermaid/parseMermaidByType.ts
+++ b/src/services/mermaid/parseMermaidByType.ts
@@ -1,31 +1,99 @@
import type { DiagramType } from '@/lib/types';
-import { parseMermaid, type ParseResult } from '@/lib/mermaidParser';
+import type { ParseResult } from '@/lib/mermaidParser';
import { getDiagramPlugin } from '@/diagram-types/core';
import { initializeDiagramTypeRuntime } from '@/diagram-types/bootstrap';
-import { detectMermaidDiagramType } from './detectDiagramType';
+import {
+ determineMermaidImportStatus,
+ normalizeMermaidImportDiagnostics,
+ type MermaidImportDiagnostic,
+ type MermaidImportStatus,
+} from './importContracts';
+import { detectMermaidDiagramType, extractMermaidDiagramHeader } from './detectDiagramType';
+import { detectMermaidWithOfficialParser } from './officialMermaidValidation';
export interface MermaidDispatchParseResult extends ParseResult {
diagramType?: DiagramType;
diagnostics?: string[];
+ structuredDiagnostics?: MermaidImportDiagnostic[];
+ importState?: MermaidImportStatus;
+ originalSource?: string;
}
export interface ParseMermaidByTypeOptions {
architectureStrictMode?: boolean;
}
-const SUPPORTED_MERMAID_FAMILIES: DiagramType[] = ['flowchart', 'stateDiagram', 'classDiagram', 'erDiagram', 'mindmap', 'journey', 'architecture'];
+const SUPPORTED_MERMAID_FAMILIES: DiagramType[] = [
+ 'flowchart',
+ 'stateDiagram',
+ 'classDiagram',
+ 'erDiagram',
+ 'mindmap',
+ 'journey',
+ 'architecture',
+ 'sequence',
+];
+
+const SUPPORTED_MERMAID_FAMILY_LIST = SUPPORTED_MERMAID_FAMILIES.join(', ');
+
+function getUnsupportedEditableModeError(typeLabel: string): string {
+ return `Mermaid "${typeLabel}" is not supported yet in editable mode. Supported families: ${SUPPORTED_MERMAID_FAMILY_LIST}.`;
+}
function getUnsupportedTypeError(diagramType: DiagramType): string {
- return `Mermaid "${diagramType}" is not supported yet in editable mode. Supported families: flowchart, stateDiagram, classDiagram, erDiagram, mindmap, journey, architecture.`;
+ return getUnsupportedEditableModeError(diagramType);
+}
+
+function getUnsupportedHeaderError(rawType: string): string {
+ return getUnsupportedEditableModeError(rawType);
}
-function applyArchitectureStrictMode(result: MermaidDispatchParseResult): MermaidDispatchParseResult {
+function finalizeResult(
+ input: string,
+ result: MermaidDispatchParseResult,
+ params: {
+ hasHeader: boolean;
+ isSupportedFamily: boolean;
+ family?: DiagramType;
+ officialMermaidAccepted?: boolean;
+ }
+): MermaidDispatchParseResult {
+ const diagnosticInput =
+ result.structuredDiagnostics
+ ?? result.diagnostics
+ ?? (result.error ? [result.error] : []);
+ const structuredDiagnostics = normalizeMermaidImportDiagnostics({
+ diagnostics: diagnosticInput,
+ family: params.family,
+ parseBlocked: Boolean(result.error),
+ officialMermaidAccepted: params.officialMermaidAccepted,
+ });
+
+ return {
+ ...result,
+ structuredDiagnostics,
+ importState:
+ result.importState
+ ?? determineMermaidImportStatus({
+ hasHeader: params.hasHeader,
+ isSupportedFamily: params.isSupportedFamily,
+ error: result.error,
+ structuredDiagnostics,
+ }),
+ originalSource: input,
+ };
+}
+
+function applyArchitectureStrictMode(
+ result: MermaidDispatchParseResult
+): MermaidDispatchParseResult {
const diagnostics = Array.isArray(result.diagnostics) ? result.diagnostics : [];
- const strictViolations = diagnostics.filter((message) => (
- message.startsWith('Invalid architecture ')
- || message.startsWith('Duplicate architecture node id')
- || message.startsWith('Recovered implicit service node')
- ));
+ const strictViolations = diagnostics.filter(
+ (message) =>
+ message.startsWith('Invalid architecture ') ||
+ message.startsWith('Duplicate architecture node id') ||
+ message.startsWith('Recovered implicit service node')
+ );
if (strictViolations.length === 0) {
return result;
@@ -39,76 +107,111 @@ function applyArchitectureStrictMode(result: MermaidDispatchParseResult): Mermai
};
}
-function normalizeLegacyStateTransitionLabels(input: string): string {
- const lines = input.replace(/\r\n/g, '\n').split('\n');
- const normalized = lines.map((rawLine) => {
- const line = rawLine.trim();
- if (line.includes('|')) return rawLine;
- const transitionMatch = line.match(/^(.+?)\s+(<-->|<--|-->|==>|-.->)\s+(.+?)\s*:\s*(.+)$/);
- if (!transitionMatch) return rawLine;
-
- const source = transitionMatch[1].trim();
- const arrow = transitionMatch[2];
- const target = transitionMatch[3].trim();
- const label = transitionMatch[4].trim();
- if (!source || !target || !label) return rawLine;
- return ` ${source} ${arrow}|${label}| ${target}`;
- });
-
- return normalized.join('\n');
-}
-
export function parseMermaidByType(
input: string,
options: ParseMermaidByTypeOptions = {}
): MermaidDispatchParseResult {
initializeDiagramTypeRuntime();
- const detectedType = detectMermaidDiagramType(input);
+ const oracleValidation = detectMermaidWithOfficialParser(input);
+ const header = extractMermaidDiagramHeader(input);
+ const detectedType = oracleValidation.detectedType ?? detectMermaidDiagramType(input);
if (!detectedType) {
- return {
- nodes: [],
- edges: [],
- error: 'Missing chart type declaration. Start with "flowchart TD", "stateDiagram-v2", or another Mermaid diagram type header.',
- };
+ if (header.rawType) {
+ return finalizeResult(
+ input,
+ {
+ nodes: [],
+ edges: [],
+ error: getUnsupportedHeaderError(header.rawType),
+ },
+ {
+ hasHeader: true,
+ isSupportedFamily: false,
+ officialMermaidAccepted: oracleValidation.isValid,
+ }
+ );
+ }
+
+ return finalizeResult(
+ input,
+ {
+ nodes: [],
+ edges: [],
+ error:
+ 'Missing chart type declaration. Start with "flowchart TD", "stateDiagram-v2", or another Mermaid diagram type header.',
+ importState: 'invalid_source',
+ },
+ {
+ hasHeader: false,
+ isSupportedFamily: false,
+ officialMermaidAccepted: oracleValidation.isValid,
+ }
+ );
}
if (!SUPPORTED_MERMAID_FAMILIES.includes(detectedType)) {
- return {
- nodes: [],
- edges: [],
- diagramType: detectedType,
- error: getUnsupportedTypeError(detectedType),
- };
+ return finalizeResult(
+ input,
+ {
+ nodes: [],
+ edges: [],
+ diagramType: detectedType,
+ error: getUnsupportedTypeError(detectedType),
+ },
+ {
+ hasHeader: true,
+ isSupportedFamily: false,
+ family: detectedType,
+ officialMermaidAccepted: oracleValidation.isValid,
+ }
+ );
}
- const canUsePluginDispatch = true;
- if (canUsePluginDispatch) {
- const plugin = getDiagramPlugin(detectedType);
- if (plugin) {
- const parsed = {
+ const plugin = getDiagramPlugin(detectedType);
+ if (plugin) {
+ const parsed = finalizeResult(
+ input,
+ {
...plugin.parseMermaid(input),
diagramType: detectedType,
- };
- if (detectedType === 'architecture' && options.architectureStrictMode) {
- return applyArchitectureStrictMode(parsed);
+ },
+ {
+ hasHeader: true,
+ isSupportedFamily: true,
+ family: detectedType,
+ officialMermaidAccepted: oracleValidation.isValid,
}
- return parsed;
+ );
+ if (detectedType === 'architecture' && options.architectureStrictMode) {
+ return finalizeResult(
+ input,
+ applyArchitectureStrictMode(parsed),
+ {
+ hasHeader: true,
+ isSupportedFamily: true,
+ family: detectedType,
+ officialMermaidAccepted: oracleValidation.isValid,
+ }
+ );
}
+ return parsed;
+ }
- return {
+ return finalizeResult(
+ input,
+ {
nodes: [],
edges: [],
diagramType: detectedType,
error: `Mermaid "${detectedType}" plugin is not registered.`,
- };
- }
-
- // Compatibility adapter for legacy state-diagram parsing until state plugin lands.
- const normalizedStateInput = normalizeLegacyStateTransitionLabels(input);
- return {
- ...parseMermaid(normalizedStateInput),
- diagramType: detectedType,
- };
+ },
+ {
+ hasHeader: true,
+ isSupportedFamily: true,
+ family: detectedType,
+ officialMermaidAccepted: oracleValidation.isValid,
+ }
+ );
}
diff --git a/src/services/mermaid/supportMatrix.test.ts b/src/services/mermaid/supportMatrix.test.ts
new file mode 100644
index 00000000..30995830
--- /dev/null
+++ b/src/services/mermaid/supportMatrix.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from 'vitest';
+import { DIAGRAM_TYPES } from '@/lib/types';
+import {
+ getMermaidFamilySupportMatrixEntry,
+ listMermaidFamilySupportMatrix,
+} from './supportMatrix';
+
+describe('mermaid support matrix', () => {
+ it('covers every supported editable Mermaid family exactly once', () => {
+ const entries = listMermaidFamilySupportMatrix();
+
+ expect(entries).toHaveLength(DIAGRAM_TYPES.length);
+ expect(entries.map((entry) => entry.family)).toEqual(
+ expect.arrayContaining([...DIAGRAM_TYPES])
+ );
+ });
+
+ it('orders families by execution priority', () => {
+ const entries = listMermaidFamilySupportMatrix();
+
+ expect(entries[0].family).toBe('flowchart');
+ expect(entries[1].family).toBe('architecture');
+ expect(entries[2].family).toBe('sequence');
+ });
+
+ it('exposes partial-support guidance for richer technical families', () => {
+ expect(getMermaidFamilySupportMatrixEntry('classDiagram').partialConstructs).toEqual(
+ expect.arrayContaining(['generics', 'visibility richness'])
+ );
+ expect(getMermaidFamilySupportMatrixEntry('erDiagram').partialConstructs).toEqual(
+ expect.arrayContaining(['constraint richness'])
+ );
+ expect(getMermaidFamilySupportMatrixEntry('sequence').partialConstructs).toEqual(
+ expect.arrayContaining(['advanced fragment fidelity'])
+ );
+ });
+});
diff --git a/src/services/mermaid/supportMatrix.ts b/src/services/mermaid/supportMatrix.ts
new file mode 100644
index 00000000..c74fad7e
--- /dev/null
+++ b/src/services/mermaid/supportMatrix.ts
@@ -0,0 +1,171 @@
+import { DIAGRAM_TYPES, type DiagramType } from '@/lib/types';
+
+export type MermaidConstructSupportStatus = 'editable' | 'partial' | 'unsupported';
+
+export interface MermaidFamilySupportMatrixEntry {
+ family: DiagramType;
+ label: string;
+ priorityRank: number;
+ goal: 'flagship' | 'core' | 'broad';
+ editableConstructs: string[];
+ partialConstructs: string[];
+ unsupportedConstructs: string[];
+}
+
+const MERMAID_FAMILY_SUPPORT_MATRIX: Record = {
+ flowchart: {
+ family: 'flowchart',
+ label: 'Flowchart',
+ priorityRank: 1,
+ goal: 'core',
+ editableConstructs: [
+ 'basic nodes and edges',
+ 'direction headers',
+ 'subgraphs',
+ 'classDef/style directives',
+ 'chained edges',
+ ],
+ partialConstructs: [
+ 'edge syntax edge cases',
+ 'complex subgraph semantics',
+ 'official-valid but unusual arrow forms',
+ ],
+ unsupportedConstructs: [],
+ },
+ architecture: {
+ family: 'architecture',
+ label: 'Architecture',
+ priorityRank: 2,
+ goal: 'flagship',
+ editableConstructs: [
+ 'services',
+ 'provider/resource typing',
+ 'official architecture edges',
+ 'strict mode validation',
+ 'provider-aware icon enrichment',
+ ],
+ partialConstructs: [
+ 'OpenFlowKit-specific labeled architecture edge extensions',
+ 'recovered implicit nodes',
+ 'title and metadata round-trip nuance',
+ ],
+ unsupportedConstructs: [],
+ },
+ sequence: {
+ family: 'sequence',
+ label: 'Sequence',
+ priorityRank: 3,
+ goal: 'core',
+ editableConstructs: [
+ 'participants',
+ 'messages',
+ 'notes',
+ 'activations',
+ 'common fragments',
+ ],
+ partialConstructs: [
+ 'advanced fragment fidelity',
+ 'visual semantics for complex nested fragments',
+ ],
+ unsupportedConstructs: [],
+ },
+ stateDiagram: {
+ family: 'stateDiagram',
+ label: 'State Diagram',
+ priorityRank: 4,
+ goal: 'core',
+ editableConstructs: [
+ 'states and transitions',
+ 'initial/final states',
+ 'direction headers',
+ 'notes',
+ 'composite blocks',
+ 'fork/join controls',
+ ],
+ partialConstructs: [
+ 'advanced state semantics beyond current editable model',
+ ],
+ unsupportedConstructs: [],
+ },
+ erDiagram: {
+ family: 'erDiagram',
+ label: 'ER Diagram',
+ priorityRank: 5,
+ goal: 'core',
+ editableConstructs: [
+ 'entities',
+ 'relations',
+ 'field parsing',
+ 'key flags',
+ ],
+ partialConstructs: [
+ 'constraint richness',
+ 'reference-field fidelity',
+ 'official-valid field metadata variants',
+ ],
+ unsupportedConstructs: [],
+ },
+ classDiagram: {
+ family: 'classDiagram',
+ label: 'Class Diagram',
+ priorityRank: 6,
+ goal: 'core',
+ editableConstructs: [
+ 'classes',
+ 'class members',
+ 'basic inheritance and relations',
+ ],
+ partialConstructs: [
+ 'generics',
+ 'visibility richness',
+ 'cardinality and advanced relation semantics',
+ ],
+ unsupportedConstructs: [],
+ },
+ mindmap: {
+ family: 'mindmap',
+ label: 'Mindmap',
+ priorityRank: 7,
+ goal: 'broad',
+ editableConstructs: [
+ 'root/branch structure',
+ 'wrapper shapes',
+ 'depth-based editing',
+ ],
+ partialConstructs: [
+ 'formatting edge cases from indentation-driven syntax',
+ ],
+ unsupportedConstructs: [],
+ },
+ journey: {
+ family: 'journey',
+ label: 'Journey',
+ priorityRank: 8,
+ goal: 'broad',
+ editableConstructs: [
+ 'title',
+ 'sections',
+ 'tasks',
+ 'scores',
+ 'actors',
+ ],
+ partialConstructs: [
+ 'visual differentiation depth',
+ 'advanced actor/task semantics',
+ ],
+ unsupportedConstructs: [],
+ },
+};
+
+export function listMermaidFamilySupportMatrix(): MermaidFamilySupportMatrixEntry[] {
+ return DIAGRAM_TYPES.map((family) => MERMAID_FAMILY_SUPPORT_MATRIX[family]).sort(
+ (left, right) => left.priorityRank - right.priorityRank
+ );
+}
+
+export function getMermaidFamilySupportMatrixEntry(
+ family: DiagramType
+): MermaidFamilySupportMatrixEntry {
+ return MERMAID_FAMILY_SUPPORT_MATRIX[family];
+}
+
diff --git a/src/services/mermaidParser.test.ts b/src/services/mermaidParser.test.ts
index 1ed0ebbb..67741977 100644
--- a/src/services/mermaidParser.test.ts
+++ b/src/services/mermaidParser.test.ts
@@ -1,196 +1,414 @@
import { describe, it, expect } from 'vitest';
+import { SECTION_MIN_HEIGHT, SECTION_MIN_WIDTH } from '@/hooks/node-operations/sectionBounds';
import { parseMermaid } from '@/lib/mermaidParser';
describe('mermaidParser', () => {
- it('should parse a basic flowchart with TD direction', () => {
- const input = `
+ it('should parse a basic flowchart with TD direction', () => {
+ const input = `
flowchart TD
A[Start] --> B[End]
`;
- const result = parseMermaid(input);
- expect(result.nodes).toHaveLength(2);
- expect(result.edges).toHaveLength(1);
- expect(result.nodes[0].data.label).toBe('Start');
- expect(result.nodes[1].data.label).toBe('End');
- expect(result.edges[0].source).toBe('A');
- expect(result.edges[0].target).toBe('B');
- });
-
- it('should handle different node types based on shapes', () => {
- const input = `
+ const result = parseMermaid(input);
+ expect(result.nodes).toHaveLength(2);
+ expect(result.edges).toHaveLength(1);
+ expect(result.nodes[0].data.label).toBe('Start');
+ expect(result.nodes[1].data.label).toBe('End');
+ expect(result.edges[0].source).toBe('A');
+ expect(result.edges[0].target).toBe('B');
+ });
+
+ it('should handle different node types based on shapes', () => {
+ const input = `
flowchart TD
S([Start Node])
P[Process Node]
D{Decision Node}
E((End Node))
`;
- const result = parseMermaid(input);
- expect(result.nodes.find(n => n.id === 'S')?.type).toBe('start');
- expect(result.nodes.find(n => n.id === 'P')?.type).toBe('process');
- expect(result.nodes.find(n => n.id === 'D')?.type).toBe('decision');
- expect(result.nodes.find(n => n.id === 'E')?.type).toBe('end');
- });
-
- it('should parse edges with labels', () => {
- const input = `
+ const result = parseMermaid(input);
+ expect(result.nodes.find((n) => n.id === 'S')?.type).toBe('start');
+ expect(result.nodes.find((n) => n.id === 'P')?.type).toBe('process');
+ expect(result.nodes.find((n) => n.id === 'D')?.type).toBe('decision');
+ expect(result.nodes.find((n) => n.id === 'E')?.type).toBe('end');
+ });
+
+ it('should parse edges with labels', () => {
+ const input = `
flowchart TD
A --> |Yes| B
A --> |No| C
`;
- const result = parseMermaid(input);
- expect(result.edges).toHaveLength(2);
- expect(result.edges[0].label).toBe('Yes');
- expect(result.edges[1].label).toBe('No');
- });
-
- it('should handle LR direction', () => {
- const input = `
+ const result = parseMermaid(input);
+ expect(result.edges).toHaveLength(2);
+ expect(result.edges[0].label).toBe('Yes');
+ expect(result.edges[1].label).toBe('No');
+ });
+
+ it('should handle LR direction', () => {
+ const input = `
flowchart LR
A --> B
`;
- const result = parseMermaid(input);
- expect(result.direction).toBe('LR');
- });
-
- it('should return error if no flowchart declaration is found', () => {
- const input = `A --> B`;
- const result = parseMermaid(input);
- expect(result.error).toBeDefined();
- expect(result.nodes).toHaveLength(0);
- });
-
- it('should handle inline node declarations in edges', () => {
- const input = `
+ const result = parseMermaid(input);
+ expect(result.direction).toBe('LR');
+ });
+
+ it('should return error if no flowchart declaration is found', () => {
+ const input = `A --> B`;
+ const result = parseMermaid(input);
+ expect(result.error).toBeDefined();
+ expect(result.nodes).toHaveLength(0);
+ });
+
+ it('should handle inline node declarations in edges', () => {
+ const input = `
flowchart TD
A[Node A] --> B((Node B))
`;
- const result = parseMermaid(input);
- expect(result.nodes).toHaveLength(2);
- expect(result.nodes.find(n => n.id === 'A')?.data.label).toBe('Node A');
- expect(result.nodes.find(n => n.id === 'B')?.type).toBe('end');
- });
+ const result = parseMermaid(input);
+ expect(result.nodes).toHaveLength(2);
+ expect(result.nodes.find((n) => n.id === 'A')?.data.label).toBe('Node A');
+ expect(result.nodes.find((n) => n.id === 'B')?.type).toBe('end');
+ });
- // --- NEW TESTS ---
+ // --- NEW TESTS ---
- it('should support "graph TD" keyword (not just flowchart)', () => {
- const input = `
+ it('should support "graph TD" keyword (not just flowchart)', () => {
+ const input = `
graph TD
A[Start] --> B[End]
`;
- const result = parseMermaid(input);
- expect(result.nodes).toHaveLength(2);
- expect(result.edges).toHaveLength(1);
- expect(result.direction).toBe('TB');
- });
-
- it('should strip fa: icon prefixes from labels', () => {
- const input = `
+ const result = parseMermaid(input);
+ expect(result.nodes).toHaveLength(2);
+ expect(result.edges).toHaveLength(1);
+ expect(result.direction).toBe('TB');
+ });
+
+ it('should strip fa: icon prefixes from labels', () => {
+ const input = `
graph TD
Bat(fa:fa-car-battery Batteries) --> ShutOff[Shut Off]
`;
- const result = parseMermaid(input);
- expect(result.nodes.find(n => n.id === 'Bat')?.data.label).toBe('Batteries');
- expect(result.nodes.find(n => n.id === 'ShutOff')?.data.label).toBe('Shut Off');
- });
+ const result = parseMermaid(input);
+ expect(result.nodes.find((n) => n.id === 'Bat')?.data.label).toBe('Batteries');
+ expect(result.nodes.find((n) => n.id === 'ShutOff')?.data.label).toBe('Shut Off');
+ });
- it('should handle chained edges: A --> B --> C', () => {
- const input = `
+ it('should handle modern @{shape: name} syntax', () => {
+ const input = `
flowchart TD
- A --> B --> C
+ A@{shape: cyl}[(Database)]
+ B@{shape: diamond}{Is Valid?}
+ C@{shape: stadium}[Start]
+ `;
+ const result = parseMermaid(input);
+ expect(result.nodes).toHaveLength(3);
+ expect(result.nodes.find((n) => n.id === 'A')?.type).toBe('process');
+ expect(result.nodes.find((n) => n.id === 'A')?.data.shape).toBe('cylinder');
+ expect(result.nodes.find((n) => n.id === 'B')?.type).toBe('decision');
+ expect(result.nodes.find((n) => n.id === 'B')?.data.shape).toBe('diamond');
+ expect(result.nodes.find((n) => n.id === 'C')?.type).toBe('start');
+ });
+
+ it('should preserve modern annotation-only labels and shapes without legacy brackets', () => {
+ const input = `
+ flowchart TD
+ API@{ shape: rect, label: "API Gateway" }
+ DB@{ shape: cyl, label: "Primary DB" }
+ API --> DB
+ `;
+ const result = parseMermaid(input);
+
+ expect(result.error).toBeUndefined();
+ expect(result.nodes.find((n) => n.id === 'API')?.data.label).toBe('API Gateway');
+ expect(result.nodes.find((n) => n.id === 'API')?.data.shape).toBe('rounded');
+ expect(result.nodes.find((n) => n.id === 'DB')?.data.label).toBe('Primary DB');
+ expect(result.nodes.find((n) => n.id === 'DB')?.data.shape).toBe('cylinder');
+ expect(result.edges).toHaveLength(1);
+ });
+
+ it('should preserve modern annotation-only endpoints when used inline in edges', () => {
+ const input = `
+ flowchart TD
+ Start@{ shape: stadium, label: "Start Here" } --> End@{ shape: circle, label: "Finish" }
+ `;
+ const result = parseMermaid(input);
+
+ expect(result.error).toBeUndefined();
+ expect(result.nodes.find((n) => n.id === 'Start')?.data.label).toBe('Start Here');
+ expect(result.nodes.find((n) => n.id === 'Start')?.type).toBe('start');
+ expect(result.nodes.find((n) => n.id === 'End')?.data.label).toBe('Finish');
+ expect(result.nodes.find((n) => n.id === 'End')?.type).toBe('end');
+ expect(result.edges).toHaveLength(1);
+ });
+
+ it('should parse dotted flowchart ids in standalone node declarations', () => {
+ const input = `
+ flowchart TD
+ api.gateway[API Gateway]
+ db.primary[(Primary DB)]
+ api.gateway --> db.primary
`;
- const result = parseMermaid(input);
- expect(result.nodes).toHaveLength(3);
- expect(result.edges).toHaveLength(2);
- expect(result.edges[0].source).toBe('A');
- expect(result.edges[0].target).toBe('B');
- expect(result.edges[1].source).toBe('B');
- expect(result.edges[1].target).toBe('C');
+ const result = parseMermaid(input);
+
+ expect(result.error).toBeUndefined();
+ expect(result.nodes.find((n) => n.id === 'api.gateway')?.data.label).toBe('API Gateway');
+ expect(result.nodes.find((n) => n.id === 'db.primary')?.data.label).toBe('Primary DB');
+ expect(result.nodes.find((n) => n.id === 'db.primary')?.data.shape).toBe('cylinder');
+ expect(result.edges[0]).toMatchObject({
+ source: 'api.gateway',
+ target: 'db.primary',
});
+ });
- it('should handle chained edges with labels', () => {
- const input = `
+ it('should parse dotted flowchart ids in inline edge endpoints and subgraphs', () => {
+ const input = `
flowchart TD
- Fuse -->|1.5a| Switch -->|1.5a| Wifi
+ subgraph cluster.api[API Cluster]
+ api.gateway[Gateway] --> service.core[Core Service]
+ end
`;
- const result = parseMermaid(input);
- expect(result.edges).toHaveLength(2);
- expect(result.edges[0].source).toBe('Fuse');
- expect(result.edges[0].target).toBe('Switch');
- expect(result.edges[0].label).toBe('1.5a');
- expect(result.edges[1].source).toBe('Switch');
- expect(result.edges[1].target).toBe('Wifi');
- expect(result.edges[1].label).toBe('1.5a');
+ const result = parseMermaid(input);
+ const sectionNode = result.nodes.find((node) => node.type === 'section');
+
+ expect(result.error).toBeUndefined();
+ expect(sectionNode?.id).toBe('cluster.api');
+ expect(sectionNode?.data.label).toBe('API Cluster');
+ expect(result.nodes.find((n) => n.id === 'api.gateway')?.parentId).toBe('cluster.api');
+ expect(result.nodes.find((n) => n.id === 'service.core')?.parentId).toBe('cluster.api');
+ expect(result.edges[0]).toMatchObject({
+ source: 'api.gateway',
+ target: 'service.core',
});
+ });
+
+ it('should strip markdown from labels', () => {
+ const input = `
+ flowchart TD
+ A[**Bold** text] --> B[*Italic* label]
+ `;
+ const result = parseMermaid(input);
+ expect(result.nodes.find((n) => n.id === 'A')?.data.label).toBe('Bold text');
+ expect(result.nodes.find((n) => n.id === 'B')?.data.label).toBe('Italic label');
+ });
- it('ignores subgraph wrappers instead of creating container nodes', () => {
- const input = `
+ it('should handle chained edges: A --> B --> C', () => {
+ const input = `
+ flowchart TD
+ A --> B --> C
+ `;
+ const result = parseMermaid(input);
+ expect(result.nodes).toHaveLength(3);
+ expect(result.edges).toHaveLength(2);
+ expect(result.edges[0].source).toBe('A');
+ expect(result.edges[0].target).toBe('B');
+ expect(result.edges[1].source).toBe('B');
+ expect(result.edges[1].target).toBe('C');
+ });
+
+ it('should handle chained edges with labels', () => {
+ const input = `
+ flowchart TD
+ Fuse -->|1.5a| Switch -->|1.5a| Wifi
+ `;
+ const result = parseMermaid(input);
+ expect(result.edges).toHaveLength(2);
+ expect(result.edges[0].source).toBe('Fuse');
+ expect(result.edges[0].target).toBe('Switch');
+ expect(result.edges[0].label).toBe('1.5a');
+ expect(result.edges[1].source).toBe('Switch');
+ expect(result.edges[1].target).toBe('Wifi');
+ expect(result.edges[1].label).toBe('1.5a');
+ });
+
+ it('creates section nodes for subgraph wrappers and sets parentId on children', () => {
+ const input = `
flowchart TD
subgraph Services
API[API]
+ DB[(Database)]
end
`;
- const result = parseMermaid(input);
- const apiNode = result.nodes.find((node) => node.id === 'API');
- expect(result.nodes).toHaveLength(1);
- expect(apiNode?.parentId).toBeUndefined();
+ const result = parseMermaid(input);
+ expect(result.nodes.length).toBeGreaterThanOrEqual(3);
+ const sectionNode = result.nodes.find((node) => node.type === 'section');
+ expect(sectionNode).toBeDefined();
+ expect(sectionNode?.data.label).toBe('Services');
+ expect(sectionNode?.style).toMatchObject({
+ width: SECTION_MIN_WIDTH,
+ height: SECTION_MIN_HEIGHT,
});
+ const apiNode = result.nodes.find((node) => node.id === 'API');
+ expect(apiNode?.parentId).toBe(sectionNode?.id);
+ expect(apiNode?.extent).toBeUndefined();
+ const dbNode = result.nodes.find((node) => node.id === 'DB');
+ expect(dbNode?.parentId).toBe(sectionNode?.id);
+ expect(dbNode?.extent).toBeUndefined();
+ });
+
+ it('parses subgraph declarations with explicit ids and human labels', () => {
+ const input = `
+ flowchart TD
+ subgraph api[API Layer]
+ A[Gateway] --> B[Service]
+ end
+ `;
+ const result = parseMermaid(input);
+ const sectionNode = result.nodes.find((node) => node.type === 'section');
- it('should handle duplicate edges between same pair', () => {
- const input = `
+ expect(sectionNode?.id).toBe('api');
+ expect(sectionNode?.data.label).toBe('API Layer');
+ expect(result.nodes.find((node) => node.id === 'A')?.parentId).toBe('api');
+ expect(result.nodes.find((node) => node.id === 'B')?.parentId).toBe('api');
+ });
+
+ it('applies classDef styles to inline node declarations used inside edges', () => {
+ const input = `
flowchart TD
- Fuse -->|10a| Cig1[Cigarette Lighter]
- Fuse -->|10a| Cig1
+ A[One]:::hot --> B[Two]
+ classDef hot fill:#f66,color:#fff,stroke:#900
`;
- const result = parseMermaid(input);
- expect(result.edges).toHaveLength(2);
- expect(result.edges[0].source).toBe('Fuse');
- expect(result.edges[1].source).toBe('Fuse');
+ const result = parseMermaid(input);
+ const nodeA = result.nodes.find((node) => node.id === 'A');
+
+ expect(nodeA?.style).toMatchObject({
+ backgroundColor: '#f66',
+ color: '#fff',
+ borderColor: '#900',
});
+ });
- it('should return direction in ParseResult', () => {
- const lr = parseMermaid('flowchart LR\n A --> B');
- expect(lr.direction).toBe('LR');
+ it('applies classDef styles referenced by Mermaid class assignment lines', () => {
+ const input = `
+ flowchart TD
+ A[Gateway]
+ B[(Primary DB)]
+ classDef hot fill:#f66,color:#fff,stroke:#900
+ class A,B hot
+ `;
+ const result = parseMermaid(input);
+ const nodeA = result.nodes.find((node) => node.id === 'A');
+ const nodeB = result.nodes.find((node) => node.id === 'B');
+
+ expect(nodeA?.style).toMatchObject({
+ backgroundColor: '#f66',
+ color: '#fff',
+ borderColor: '#900',
+ });
+ expect(nodeB?.style).toMatchObject({
+ backgroundColor: '#f66',
+ color: '#fff',
+ borderColor: '#900',
+ });
+ });
- const rl = parseMermaid('graph RL\n A --> B');
- expect(rl.direction).toBe('RL');
+ it('applies class assignment lines to dotted node ids', () => {
+ const input = `
+ flowchart TD
+ api.gateway[Gateway]
+ service.core[Core Service]
+ classDef selected fill:#dff,stroke:#08c,color:#024
+ class api.gateway,service.core selected;
+ `;
+ const result = parseMermaid(input);
- const bt = parseMermaid('flowchart BT\n A --> B');
- expect(bt.direction).toBe('BT');
+ expect(result.nodes.find((node) => node.id === 'api.gateway')?.style).toMatchObject({
+ backgroundColor: '#dff',
+ borderColor: '#08c',
+ color: '#024',
});
+ expect(result.nodes.find((node) => node.id === 'service.core')?.style).toMatchObject({
+ backgroundColor: '#dff',
+ borderColor: '#08c',
+ color: '#024',
+ });
+ });
+
+ it('applies style directives to dotted node ids', () => {
+ const input = `
+ flowchart TD
+ api.gateway[Gateway]
+ style api.gateway fill:#dff,stroke:#08c,color:#024
+ `;
+ const result = parseMermaid(input);
+
+ expect(result.error).toBeUndefined();
+ expect(result.nodes.find((node) => node.id === 'api.gateway')?.style).toMatchObject({
+ backgroundColor: '#dff',
+ borderColor: '#08c',
+ color: '#024',
+ });
+ });
+
+ it('preserves nested flowchart subgraph parenting', () => {
+ const input = `
+ flowchart TD
+ subgraph platform[Platform]
+ subgraph api[API]
+ gateway[Gateway] --> service[Service]
+ end
+ end
+ `;
+ const result = parseMermaid(input);
+
+ expect(result.error).toBeUndefined();
+ expect(result.nodes.find((node) => node.id === 'api')?.parentId).toBe('platform');
+ expect(result.nodes.find((node) => node.id === 'gateway')?.parentId).toBe('api');
+ expect(result.nodes.find((node) => node.id === 'service')?.parentId).toBe('api');
+ });
+
+ it('should handle duplicate edges between same pair', () => {
+ const input = `
+ flowchart TD
+ Fuse -->|10a| Cig1[Cigarette Lighter]
+ Fuse -->|10a| Cig1
+ `;
+ const result = parseMermaid(input);
+ expect(result.edges).toHaveLength(2);
+ expect(result.edges[0].source).toBe('Fuse');
+ expect(result.edges[1].source).toBe('Fuse');
+ });
+
+ it('should return direction in ParseResult', () => {
+ const lr = parseMermaid('flowchart LR\n A --> B');
+ expect(lr.direction).toBe('LR');
+
+ const rl = parseMermaid('graph RL\n A --> B');
+ expect(rl.direction).toBe('RL');
- it('should skip linkStyle, classDef, style directives gracefully', () => {
- const input = `
+ const bt = parseMermaid('flowchart BT\n A --> B');
+ expect(bt.direction).toBe('BT');
+ });
+
+ it('should skip linkStyle, classDef, style directives gracefully', () => {
+ const input = `
graph TD
A --> B
linkStyle 0 stroke-width:2px,fill:none,stroke:red;
classDef default fill:#f9f
style A fill:#bbf
`;
- const result = parseMermaid(input);
- expect(result.nodes).toHaveLength(2);
- expect(result.edges).toHaveLength(1);
- expect(result.error).toBeUndefined();
- });
-
- it('should parse linkStyle and apply stroke color to edges', () => {
- const input = `
+ const result = parseMermaid(input);
+ expect(result.nodes).toHaveLength(2);
+ expect(result.edges).toHaveLength(1);
+ expect(result.error).toBeUndefined();
+ });
+
+ it('should parse linkStyle and apply stroke color to edges', () => {
+ const input = `
graph TD
A --> B
B --> C
linkStyle 0 stroke-width:2px,fill:none,stroke:red;
linkStyle 1 stroke-width:2px,fill:none,stroke:green;
`;
- const result = parseMermaid(input);
- expect(result.edges[0].style).toEqual(
- expect.objectContaining({ stroke: 'red' })
- );
- expect(result.edges[1].style).toEqual(
- expect.objectContaining({ stroke: 'green' })
- );
- });
+ const result = parseMermaid(input);
+ expect(result.edges[0].style).toEqual(expect.objectContaining({ stroke: 'red' }));
+ expect(result.edges[1].style).toEqual(expect.objectContaining({ stroke: 'green' }));
+ });
- it('should handle the full battery diagram', () => {
- const input = `graph TD
+ it('should handle the full battery diagram', () => {
+ const input = `graph TD
Bat(fa:fa-car-battery Batteries) -->|150a 50mm| ShutOff
Bat -->|150a 50mm| Shunt
@@ -236,125 +454,117 @@ describe('mermaidParser', () => {
linkStyle 18 stroke-width:2px,fill:none,stroke:green;
linkStyle 19 stroke-width:2px,fill:none,stroke:green;`;
- const result = parseMermaid(input);
-
- // Should have no errors
- expect(result.error).toBeUndefined();
-
- // Direction should be TB
- expect(result.direction).toBe('TB');
-
- // Should find all unique nodes
- const nodeIds = result.nodes.map(n => n.id);
- expect(nodeIds).toContain('Bat');
- expect(nodeIds).toContain('ShutOff');
- expect(nodeIds).toContain('Shunt');
- expect(nodeIds).toContain('BusPos');
- expect(nodeIds).toContain('BusNeg');
- expect(nodeIds).toContain('Fuse');
- expect(nodeIds).toContain('Old');
- expect(nodeIds).toContain('USB');
- expect(nodeIds).toContain('Switch');
- expect(nodeIds).toContain('Wifi');
- expect(nodeIds).toContain('Cig1');
- expect(nodeIds).toContain('Cig2');
- expect(nodeIds).toContain('Solar');
- expect(nodeIds).toContain('SolarCont');
-
- // Check labels
- expect(result.nodes.find(n => n.id === 'Bat')?.data.label).toBe('Batteries');
- expect(result.nodes.find(n => n.id === 'ShutOff')?.data.label).toBe('Shut Off');
- expect(result.nodes.find(n => n.id === 'BusPos')?.data.label).toBe('Bus Bar +');
- expect(result.nodes.find(n => n.id === 'USB')?.data.label).toBe('USB-C');
-
- // Check that Old is a decision node (diamond shape)
- expect(result.nodes.find(n => n.id === 'Old')?.type).toBe('decision');
-
- // Should have many edges (20 in the original)
- expect(result.edges.length).toBeGreaterThanOrEqual(18);
-
- // Check edge labels
- const batToShutoff = result.edges.find(e => e.source === 'Bat' && e.target === 'ShutOff');
- expect(batToShutoff?.label).toBe('150a 50mm');
-
- // Check linkStyle applied colors
- expect(result.edges[0].style).toEqual(
- expect.objectContaining({ stroke: 'red' })
- );
- });
-
- it('should handle dotted arrow -.-> ', () => {
- const input = `
+ const result = parseMermaid(input);
+
+ // Should have no errors
+ expect(result.error).toBeUndefined();
+
+ // Direction should be TB
+ expect(result.direction).toBe('TB');
+
+ // Should find all unique nodes
+ const nodeIds = result.nodes.map((n) => n.id);
+ expect(nodeIds).toContain('Bat');
+ expect(nodeIds).toContain('ShutOff');
+ expect(nodeIds).toContain('Shunt');
+ expect(nodeIds).toContain('BusPos');
+ expect(nodeIds).toContain('BusNeg');
+ expect(nodeIds).toContain('Fuse');
+ expect(nodeIds).toContain('Old');
+ expect(nodeIds).toContain('USB');
+ expect(nodeIds).toContain('Switch');
+ expect(nodeIds).toContain('Wifi');
+ expect(nodeIds).toContain('Cig1');
+ expect(nodeIds).toContain('Cig2');
+ expect(nodeIds).toContain('Solar');
+ expect(nodeIds).toContain('SolarCont');
+
+ // Check labels
+ expect(result.nodes.find((n) => n.id === 'Bat')?.data.label).toBe('Batteries');
+ expect(result.nodes.find((n) => n.id === 'ShutOff')?.data.label).toBe('Shut Off');
+ expect(result.nodes.find((n) => n.id === 'BusPos')?.data.label).toBe('Bus Bar +');
+ expect(result.nodes.find((n) => n.id === 'USB')?.data.label).toBe('USB-C');
+
+ // Check that Old is a decision node (diamond shape)
+ expect(result.nodes.find((n) => n.id === 'Old')?.type).toBe('decision');
+
+ // Should have many edges (20 in the original)
+ expect(result.edges.length).toBeGreaterThanOrEqual(18);
+
+ // Check edge labels
+ const batToShutoff = result.edges.find((e) => e.source === 'Bat' && e.target === 'ShutOff');
+ expect(batToShutoff?.label).toBe('150a 50mm');
+
+ // Check linkStyle applied colors
+ expect(result.edges[0].style).toEqual(expect.objectContaining({ stroke: 'red' }));
+ });
+
+ it('should handle dotted arrow -.-> ', () => {
+ const input = `
flowchart TD
A -.-> B
`;
- const result = parseMermaid(input);
- expect(result.edges).toHaveLength(1);
- expect(result.edges[0].style).toEqual(
- expect.objectContaining({ strokeDasharray: '5 3' })
- );
- });
+ const result = parseMermaid(input);
+ expect(result.edges).toHaveLength(1);
+ expect(result.edges[0].style).toEqual(expect.objectContaining({ strokeDasharray: '5 3' }));
+ });
- it('should handle thick arrow ==>', () => {
- const input = `
+ it('should handle thick arrow ==>', () => {
+ const input = `
flowchart TD
A ==> B
`;
- const result = parseMermaid(input);
- expect(result.edges).toHaveLength(1);
- expect(result.edges[0].style).toEqual(
- expect.objectContaining({ strokeWidth: 4 })
- );
- });
+ const result = parseMermaid(input);
+ expect(result.edges).toHaveLength(1);
+ expect(result.edges[0].style).toEqual(expect.objectContaining({ strokeWidth: 4 }));
+ });
- it('should handle thick arrow ==> with inline label', () => {
- const input = `
+ it('should handle thick arrow ==> with inline label', () => {
+ const input = `
flowchart TD
A == Yes ==> B
`;
- const result = parseMermaid(input);
- expect(result.edges).toHaveLength(1);
- expect(result.edges[0].style).toEqual(
- expect.objectContaining({ strokeWidth: 4 })
- );
- expect(result.edges[0].label).toBe('Yes');
- });
-
- it('should handle reverse arrow <-- with markerStart', () => {
- const input = `
+ const result = parseMermaid(input);
+ expect(result.edges).toHaveLength(1);
+ expect(result.edges[0].style).toEqual(expect.objectContaining({ strokeWidth: 4 }));
+ expect(result.edges[0].label).toBe('Yes');
+ });
+
+ it('should handle reverse arrow <-- with markerStart', () => {
+ const input = `
flowchart TD
A <-- B
`;
- const result = parseMermaid(input);
- expect(result.edges).toHaveLength(1);
- expect(result.edges[0].markerStart).toBeDefined();
- expect(result.edges[0].markerEnd).toBeUndefined();
- });
-
- it('should handle bidirectional arrow <--> with both markers', () => {
- const input = `
+ const result = parseMermaid(input);
+ expect(result.edges).toHaveLength(1);
+ expect(result.edges[0].markerStart).toBeDefined();
+ expect(result.edges[0].markerEnd).toBeUndefined();
+ });
+
+ it('should handle bidirectional arrow <--> with both markers', () => {
+ const input = `
flowchart TD
A <--> B
`;
- const result = parseMermaid(input);
- expect(result.edges).toHaveLength(1);
- expect(result.edges[0].markerStart).toBeDefined();
- expect(result.edges[0].markerEnd).toBeDefined();
- });
-
- it('should handle multiline quoted strings', () => {
- const input = `
+ const result = parseMermaid(input);
+ expect(result.edges).toHaveLength(1);
+ expect(result.edges[0].markerStart).toBeDefined();
+ expect(result.edges[0].markerEnd).toBeDefined();
+ });
+
+ it('should handle multiline quoted strings', () => {
+ const input = `
graph TD
A["Line 1
Line 2"]
`;
- const result = parseMermaid(input);
- expect(result.nodes).toHaveLength(1);
- expect(result.nodes[0].data.label).toBe('Line 1\nLine 2');
- });
+ const result = parseMermaid(input);
+ expect(result.nodes).toHaveLength(1);
+ expect(result.nodes[0].data.label).toBe('Line 1\nLine 2');
+ });
- it('should handle the Service Learning example', () => {
- const input = `graph TB
+ it('should handle the Service Learning example', () => {
+ const input = `graph TB
A("Do you think online service
learning is right for you?")
B("Do you have time to design
@@ -375,26 +585,57 @@ D--No-->E
E--Yes-->F
E--No-->C`;
- const result = parseMermaid(input);
-
- // Should parse 6 nodes
- expect(result.nodes).toHaveLength(6);
- // ID A should have multiline label
- const nodeA = result.nodes.find(n => n.id === 'A');
- expect(nodeA).toBeDefined();
- expect(nodeA?.data.label).toContain('online service\nlearning');
-
- // Should parse 8 edges
- expect(result.edges).toHaveLength(8);
-
- // Check specific edges
- const startYes = result.edges.find(e => e.source === 'A' && e.target === 'B');
- expect(startYes).toBeDefined();
- expect(startYes?.label).toBe('Yes');
- expect(startYes?.style?.strokeWidth).toBe(4); // ==> is thick
-
- const startNo = result.edges.find(e => e.source === 'A' && e.target === 'C');
- expect(startNo).toBeDefined();
- expect(startNo?.label).toBe('No');
- });
+ const result = parseMermaid(input);
+
+ // Should parse 6 nodes
+ expect(result.nodes).toHaveLength(6);
+ // ID A should have multiline label
+ const nodeA = result.nodes.find((n) => n.id === 'A');
+ expect(nodeA).toBeDefined();
+ expect(nodeA?.data.label).toContain('online service\nlearning');
+
+ // Should parse 8 edges
+ expect(result.edges).toHaveLength(8);
+
+ // Check specific edges
+ const startYes = result.edges.find((e) => e.source === 'A' && e.target === 'B');
+ expect(startYes).toBeDefined();
+ expect(startYes?.label).toBe('Yes');
+ expect(startYes?.style?.strokeWidth).toBe(4); // ==> is thick
+
+ const startNo = result.edges.find((e) => e.source === 'A' && e.target === 'C');
+ expect(startNo).toBeDefined();
+ expect(startNo?.label).toBe('No');
+ });
+
+ it('emits diagnostics for malformed flowchart blocks and unrecognized lines', () => {
+ const input = `
+ flowchart TD
+ subgraph
+ A --> B
+ orphan text
+ end
+ end
+ `;
+
+ const result = parseMermaid(input);
+ expect(result.error).toBeUndefined();
+ expect(result.nodes.length).toBeGreaterThan(0);
+ expect(result.diagnostics?.some((message) => message.includes('Invalid flowchart subgraph declaration at line'))).toBe(true);
+ expect(result.diagnostics?.some((message) => message.includes('Unrecognized flowchart line at line'))).toBe(true);
+ expect(result.diagnostics?.some((message) => message.includes('Unexpected flowchart block closer at line'))).toBe(true);
+ });
+
+ it('emits diagnostics when flowchart subgraph blocks are left unclosed', () => {
+ const input = `
+ flowchart TD
+ subgraph api[API]
+ A --> B
+ `;
+
+ const result = parseMermaid(input);
+ expect(result.error).toBeUndefined();
+ expect(result.nodes.length).toBeGreaterThan(0);
+ expect(result.diagnostics?.some((message) => message.includes('Unclosed flowchart block detected'))).toBe(true);
+ });
});
diff --git a/src/services/openFlowRoundTripGoldenFixtures.ts b/src/services/openFlowRoundTripGoldenFixtures.ts
index b07ad8cd..ffbe9ee8 100644
--- a/src/services/openFlowRoundTripGoldenFixtures.ts
+++ b/src/services/openFlowRoundTripGoldenFixtures.ts
@@ -1,3 +1,4 @@
+import type { CSSProperties } from 'react';
import type { Edge, Node } from '@/lib/reactflowCompat';
export interface OpenFlowRoundTripGoldenFixture {
@@ -39,6 +40,49 @@ function createEdge(id: string, source: string, target: string, label?: string):
} as Edge;
}
+function createArchNode(
+ id: string,
+ label: string,
+ archIconPackId: string,
+ archIconShapeId: string,
+ color: string
+): Node {
+ return {
+ id,
+ type: 'custom',
+ position: { x: 0, y: 0 },
+ data: {
+ label,
+ color,
+ archIconPackId,
+ archIconShapeId,
+ },
+ } as Node;
+}
+
+function createEdgeWithStyle(
+ id: string,
+ source: string,
+ target: string,
+ label?: string,
+ style?: { type?: string; strokeDasharray?: string; strokeWidth?: number }
+): Edge {
+ const edge: Record = {
+ id,
+ source,
+ target,
+ label,
+ };
+ if (style?.type) edge.type = style.type;
+ if (style?.strokeDasharray || style?.strokeWidth) {
+ edge.style = {
+ ...(style.strokeDasharray ? { strokeDasharray: style.strokeDasharray } : {}),
+ ...(style.strokeWidth ? { strokeWidth: style.strokeWidth } : {}),
+ } as CSSProperties;
+ }
+ return edge as Edge;
+}
+
export const OPENFLOW_ROUND_TRIP_GOLDEN_FIXTURES: OpenFlowRoundTripGoldenFixture[] = [
{
name: 'simple-linear',
@@ -74,4 +118,28 @@ export const OPENFLOW_ROUND_TRIP_GOLDEN_FIXTURES: OpenFlowRoundTripGoldenFixture
createEdge('e1', 'n1', 'n3', 'ok'),
],
},
+ {
+ name: 'arch-icons',
+ nodes: [
+ createArchNode('lambda', 'Lambda', 'aws-official-starter-v1', 'compute-lambda', 'violet'),
+ createArchNode('sqs', 'SQS Queue', 'aws-official-starter-v1', 'app-integration-sqs', 'amber'),
+ createArchNode('dynamo', 'DynamoDB', 'aws-official-starter-v1', 'database-dynamodb', 'emerald'),
+ ],
+ edges: [
+ createEdge('e1', 'lambda', 'sqs', 'publish'),
+ createEdge('e2', 'sqs', 'dynamo', 'write'),
+ ],
+ },
+ {
+ name: 'edge-styles',
+ nodes: [
+ createNode('n1', 'Source', 'process'),
+ createNode('n2', 'Dashed Target', 'process'),
+ createNode('n3', 'Curved Target', 'process'),
+ ],
+ edges: [
+ createEdgeWithStyle('e1', 'n1', 'n2', undefined, { strokeDasharray: '5 5' }),
+ createEdgeWithStyle('e2', 'n1', 'n3', 'flow', { type: 'smoothstep' }),
+ ],
+ },
];
diff --git a/src/services/remainingFamiliesRoundTrip.test.ts b/src/services/remainingFamiliesRoundTrip.test.ts
index 28f1a521..0f910890 100644
--- a/src/services/remainingFamiliesRoundTrip.test.ts
+++ b/src/services/remainingFamiliesRoundTrip.test.ts
@@ -6,10 +6,10 @@ describe('remaining Mermaid family round-trip', () => {
it('preserves mindmap family through parse/export/parse', () => {
const source = `
mindmap
- Root
- Child A
+ root((Root))
+ feature[[Child A]]
Grandchild
- Child B
+ (Child B)
`;
const first = parseMermaidByType(source);
@@ -19,12 +19,49 @@ describe('remaining Mermaid family round-trip', () => {
const exported = toMermaid(first.nodes, first.edges);
expect(exported.startsWith('mindmap')).toBe(true);
+ expect(exported).toContain('root((Root))');
+ expect(exported).toContain('feature[[Child A]]');
+ expect(exported).toContain('((Root))');
+ expect(exported).toContain('[[Child A]]');
+ expect(exported).toContain('(Child B)');
const second = parseMermaidByType(exported);
expect(second.error).toBeUndefined();
expect(second.diagramType).toBe('mindmap');
expect(second.nodes).toHaveLength(first.nodes.length);
expect(second.edges).toHaveLength(first.edges.length);
+ expect(second.nodes.find((node) => node.data.label === 'Root')?.data.mindmapAlias).toBe('root');
+ expect(second.nodes.find((node) => node.data.label === 'Child A')?.data.mindmapAlias).toBe('feature');
+ expect(second.nodes.find((node) => node.data.label === 'Root')?.data.mindmapWrapper).toBe('double-circle');
+ expect(second.nodes.find((node) => node.data.label === 'Child A')?.data.mindmapWrapper).toBe('double-square');
+ expect(second.nodes.find((node) => node.data.label === 'Child B')?.data.mindmapWrapper).toBe('rounded');
+ });
+
+ it('preserves dotted mindmap aliases through parse/export/parse', () => {
+ const source = `
+ mindmap
+ platform.root((Root))
+ platform.api[[Child A]]
+ platform.branch(Child B)
+ `;
+
+ const first = parseMermaidByType(source);
+ expect(first.error).toBeUndefined();
+ expect(first.diagramType).toBe('mindmap');
+ expect(first.nodes.find((node) => node.data.label === 'Root')?.data.mindmapAlias).toBe('platform.root');
+ expect(first.nodes.find((node) => node.data.label === 'Child A')?.data.mindmapAlias).toBe('platform.api');
+
+ const exported = toMermaid(first.nodes, first.edges);
+ expect(exported).toContain('platform.root((Root))');
+ expect(exported).toContain('platform.api[[Child A]]');
+ expect(exported).toContain('platform.branch(Child B)');
+
+ const second = parseMermaidByType(exported);
+ expect(second.error).toBeUndefined();
+ expect(second.diagramType).toBe('mindmap');
+ expect(second.nodes.find((node) => node.data.label === 'Root')?.data.mindmapAlias).toBe('platform.root');
+ expect(second.nodes.find((node) => node.data.label === 'Child A')?.data.mindmapAlias).toBe('platform.api');
+ expect(second.nodes.find((node) => node.data.label === 'Child B')?.data.mindmapAlias).toBe('platform.branch');
});
it('preserves journey family through parse/export/parse', () => {
@@ -45,12 +82,42 @@ describe('remaining Mermaid family round-trip', () => {
const exported = toMermaid(first.nodes, first.edges);
expect(exported.startsWith('journey')).toBe(true);
+ expect(exported).toContain('title Checkout');
const second = parseMermaidByType(exported);
expect(second.error).toBeUndefined();
expect(second.diagramType).toBe('journey');
expect(second.nodes).toHaveLength(first.nodes.length);
expect(second.edges).toHaveLength(first.edges.length);
+ expect(second.nodes[0].data.journeyTitle).toBe('Checkout');
+ });
+
+ it('preserves journey steps with colon-rich task and actor text through parse/export/parse', () => {
+ const source = `
+ journey
+ title Incident Response
+ section Alerts
+ HTTP: 500 Error: 1: SRE: On-call
+ Recover service: 4: API: Team
+ `;
+
+ const first = parseMermaidByType(source);
+ expect(first.error).toBeUndefined();
+ expect(first.diagramType).toBe('journey');
+ expect(first.nodes[0].data.journeyTask).toBe('HTTP: 500 Error');
+ expect(first.nodes[0].data.journeyActor).toBe('SRE: On-call');
+
+ const exported = toMermaid(first.nodes, first.edges);
+ expect(exported.startsWith('journey')).toBe(true);
+ expect(exported).toContain('HTTP: 500 Error: 1: SRE: On-call');
+ expect(exported).toContain('Recover service: 4: API: Team');
+
+ const second = parseMermaidByType(exported);
+ expect(second.error).toBeUndefined();
+ expect(second.diagramType).toBe('journey');
+ expect(second.nodes[0].data.journeyTask).toBe('HTTP: 500 Error');
+ expect(second.nodes[0].data.journeyActor).toBe('SRE: On-call');
+ expect(second.nodes[1].data.journeyActor).toBe('API: Team');
});
it('preserves classDiagram family relation semantics through parse/export/parse', () => {
@@ -61,7 +128,7 @@ describe('remaining Mermaid family round-trip', () => {
+name: String
}
class Account
- User o-- Account : owns
+ User "1" o-- "*" Account : owns
`;
const first = parseMermaidByType(source);
@@ -72,7 +139,7 @@ describe('remaining Mermaid family round-trip', () => {
const exported = toMermaid(first.nodes, first.edges);
expect(exported.startsWith('classDiagram')).toBe(true);
- expect(exported).toContain('User o-- Account : owns');
+ expect(exported).toContain('User "1" o-- "*" Account : owns');
const second = parseMermaidByType(exported);
expect(second.error).toBeUndefined();
@@ -81,6 +148,59 @@ describe('remaining Mermaid family round-trip', () => {
expect(second.edges).toHaveLength(first.edges.length);
expect(second.edges[0].data?.classRelation).toBe('o--');
expect(second.edges[0].data?.classRelationLabel).toBe('owns');
+ expect(second.edges[0].data?.classRelationSourceCardinality).toBe('1');
+ expect(second.edges[0].data?.classRelationTargetCardinality).toBe('*');
+ });
+
+ it('preserves classDiagram generic identifiers through parse/export/parse', () => {
+ const source = `
+ classDiagram
+ class Repository~T~ {
+ +findById(id: UUID): T
+ }
+ class User
+ Repository~T~ "1" --> "*" User : stores
+ `;
+
+ const first = parseMermaidByType(source);
+ expect(first.error).toBeUndefined();
+ expect(first.diagramType).toBe('classDiagram');
+ expect(first.nodes.find((node) => node.id === 'Repository')).toBeDefined();
+
+ const exported = toMermaid(first.nodes, first.edges);
+ expect(exported).toContain('class Repository~T~ {');
+ expect(exported).toContain('Repository~T~ "1" --> "*" User : stores');
+
+ const second = parseMermaidByType(exported);
+ expect(second.error).toBeUndefined();
+ expect(second.diagramType).toBe('classDiagram');
+ expect(second.nodes.find((node) => node.id === 'Repository')).toBeDefined();
+ expect(second.edges[0].data?.classRelationSourceCardinality).toBe('1');
+ expect(second.edges[0].data?.classRelationTargetCardinality).toBe('*');
+ });
+
+ it('preserves classDiagram multi-parameter generic identifiers through parse/export/parse', () => {
+ const source = `
+ classDiagram
+ class Map~K, V~
+ class Entry
+ Map~K, V~ --> Entry : stores
+ `;
+
+ const first = parseMermaidByType(source);
+ expect(first.error).toBeUndefined();
+ expect(first.diagramType).toBe('classDiagram');
+ expect(first.nodes.find((node) => node.id === 'Map')).toBeDefined();
+
+ const exported = toMermaid(first.nodes, first.edges);
+ expect(exported).toContain('class Map~K, V~');
+ expect(exported).toContain('Map~K, V~ --> Entry : stores');
+
+ const second = parseMermaidByType(exported);
+ expect(second.error).toBeUndefined();
+ expect(second.diagramType).toBe('classDiagram');
+ expect(second.nodes.find((node) => node.id === 'Map')).toBeDefined();
+ expect(second.edges[0].data?.classRelationLabel).toBe('stores');
});
it('preserves erDiagram family relation semantics through parse/export/parse', () => {
@@ -113,4 +233,290 @@ describe('remaining Mermaid family round-trip', () => {
expect(second.edges[0].data?.erRelation).toBe('||--o{');
expect(second.edges[0].data?.erRelationLabel).toBe('places');
});
+
+ it('preserves erDiagram field metadata through parse/export/parse', () => {
+ const source = `
+ erDiagram
+ ORDER {
+ uuid id PK
+ uuid customer_id FK REFERENCES CUSTOMER.id
+ string external_id UNIQUE
+ timestamp created_at NN
+ }
+ `;
+
+ const first = parseMermaidByType(source);
+ expect(first.error).toBeUndefined();
+ expect(first.diagramType).toBe('erDiagram');
+
+ const exported = toMermaid(first.nodes, first.edges);
+ expect(exported).toContain('uuid id PK');
+ expect(exported).toContain('uuid customer_id FK REFERENCES CUSTOMER');
+ expect(exported).toContain('string external_id UK');
+ expect(exported).toContain('timestamp created_at NN');
+
+ const second = parseMermaidByType(exported);
+ expect(second.error).toBeUndefined();
+ expect(second.diagramType).toBe('erDiagram');
+
+ const fields = second.nodes[0].data.erFields ?? [];
+ expect(fields).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ name: 'id',
+ dataType: 'uuid',
+ isPrimaryKey: true,
+ }),
+ expect.objectContaining({
+ name: 'customer_id',
+ dataType: 'uuid',
+ isForeignKey: true,
+ referencesTable: 'CUSTOMER',
+ }),
+ expect.objectContaining({
+ name: 'external_id',
+ dataType: 'string',
+ isUnique: true,
+ }),
+ expect.objectContaining({
+ name: 'created_at',
+ dataType: 'timestamp',
+ isNotNull: true,
+ }),
+ ])
+ );
+ });
+
+ it('preserves dotted erDiagram REFERENCES targets through parse/export/parse', () => {
+ const source = `
+ erDiagram
+ ORDER {
+ uuid customer_id FK REFERENCES billing.Customer.id
+ }
+ `;
+
+ const first = parseMermaidByType(source);
+ expect(first.error).toBeUndefined();
+ expect(first.diagramType).toBe('erDiagram');
+
+ const exported = toMermaid(first.nodes, first.edges);
+ expect(exported).toContain('uuid customer_id FK REFERENCES billing.Customer.id');
+
+ const second = parseMermaidByType(exported);
+ expect(second.error).toBeUndefined();
+ expect(second.diagramType).toBe('erDiagram');
+ const fields = second.nodes[0].data.erFields ?? [];
+ expect(fields).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ name: 'customer_id',
+ isForeignKey: true,
+ referencesTable: 'billing.Customer',
+ referencesField: 'id',
+ }),
+ ])
+ );
+ });
+
+ it('preserves sequence notes and aliases through parse/export/parse', () => {
+ const source = `
+ sequenceDiagram
+ actor U as User
+ participant API as Backend API
+ note over U, API: warm path
+ U->>API: Request
+ API-->>U: Response
+ `;
+
+ const first = parseMermaidByType(source);
+ expect(first.error).toBeUndefined();
+ expect(first.diagramType).toBe('sequence');
+ expect(first.nodes.some((node) => node.type === 'sequence_note')).toBe(true);
+
+ const exported = toMermaid(first.nodes, first.edges);
+ expect(exported.startsWith('sequenceDiagram')).toBe(true);
+ expect(exported).toContain('actor U as User');
+ expect(exported).toContain('participant API as Backend API');
+ expect(exported).toContain('note over U, API: warm path');
+
+ const second = parseMermaidByType(exported);
+ expect(second.error).toBeUndefined();
+ expect(second.diagramType).toBe('sequence');
+ const noteNode = second.nodes.find((node) => node.type === 'sequence_note');
+ expect(noteNode?.data.seqNoteTargets).toEqual(['U', 'API']);
+ expect(second.edges).toHaveLength(first.edges.length);
+ });
+
+ it('preserves sequence activation commands through parse/export/parse', () => {
+ const source = `
+ sequenceDiagram
+ participant A
+ participant B
+ A->>B: Request
+ activate B
+ B-->>A: Response
+ deactivate B
+ `;
+
+ const first = parseMermaidByType(source);
+ expect(first.error).toBeUndefined();
+ expect(first.diagramType).toBe('sequence');
+
+ const exported = toMermaid(first.nodes, first.edges);
+ expect(exported).toContain('activate B');
+ expect(exported).toContain('deactivate B');
+
+ const second = parseMermaidByType(exported);
+ expect(second.error).toBeUndefined();
+ expect(second.diagramType).toBe('sequence');
+ expect(second.nodes.find((node) => node.id === 'B')?.data.seqActivations).toEqual([
+ { order: 1, activate: true },
+ { order: 2, activate: false },
+ ]);
+ });
+
+ it('preserves sequence alt/else branches through parse/export/parse', () => {
+ const source = `
+ sequenceDiagram
+ participant A
+ participant B
+ alt success
+ A->>B: Request
+ else failure
+ B-->>A: Error
+ end
+ `;
+
+ const first = parseMermaidByType(source);
+ expect(first.error).toBeUndefined();
+ expect(first.diagramType).toBe('sequence');
+
+ const exported = toMermaid(first.nodes, first.edges);
+ expect(exported).toContain('alt success');
+ expect(exported).toContain('else failure');
+ expect(exported).not.toContain('alt failure');
+
+ const second = parseMermaidByType(exported);
+ expect(second.error).toBeUndefined();
+ expect(second.diagramType).toBe('sequence');
+ expect(second.edges[0].data?.seqFragment).toMatchObject({
+ type: 'alt',
+ condition: 'success',
+ branchKind: 'start',
+ });
+ expect(second.edges[1].data?.seqFragment).toMatchObject({
+ type: 'alt',
+ condition: 'failure',
+ branchKind: 'else',
+ });
+ });
+
+ it('preserves sequence par/and branches through parse/export/parse', () => {
+ const source = `
+ sequenceDiagram
+ participant A
+ participant B
+ participant C
+ par fast lane
+ A->>B: Request
+ and slow lane
+ A->>C: Request
+ end
+ `;
+
+ const first = parseMermaidByType(source);
+ expect(first.error).toBeUndefined();
+ expect(first.diagramType).toBe('sequence');
+
+ const exported = toMermaid(first.nodes, first.edges);
+ expect(exported).toContain('par fast lane');
+ expect(exported).toContain('and slow lane');
+ expect(exported).not.toContain('par slow lane');
+
+ const second = parseMermaidByType(exported);
+ expect(second.error).toBeUndefined();
+ expect(second.diagramType).toBe('sequence');
+ expect(second.edges[0].data?.seqFragment).toMatchObject({
+ type: 'par',
+ condition: 'fast lane',
+ branchKind: 'start',
+ });
+ expect(second.edges[1].data?.seqFragment).toMatchObject({
+ type: 'par',
+ condition: 'slow lane',
+ branchKind: 'and',
+ });
+ });
+
+ it('preserves sequence notes inside fragment blocks through parse/export/parse', () => {
+ const source = `
+ sequenceDiagram
+ participant A
+ participant B
+ alt success
+ note over A, B: Shared context
+ A->>B: Request
+ else failure
+ B-->>A: Error
+ end
+ `;
+
+ const first = parseMermaidByType(source);
+ expect(first.error).toBeUndefined();
+ expect(first.diagramType).toBe('sequence');
+ expect(first.nodes.find((node) => node.type === 'sequence_note')?.data.seqFragment).toMatchObject({
+ type: 'alt',
+ condition: 'success',
+ branchKind: 'start',
+ });
+
+ const exported = toMermaid(first.nodes, first.edges);
+ expect(exported).toContain('alt success');
+ expect(exported).toContain('note over A, B: Shared context');
+ expect(exported).toContain('else failure');
+
+ const second = parseMermaidByType(exported);
+ expect(second.error).toBeUndefined();
+ expect(second.diagramType).toBe('sequence');
+ expect(second.nodes.find((node) => node.type === 'sequence_note')?.data.seqFragment).toMatchObject({
+ type: 'alt',
+ condition: 'success',
+ branchKind: 'start',
+ });
+ });
+
+ it('preserves sequence critical/option branches through parse/export/parse', () => {
+ const source = `
+ sequenceDiagram
+ participant A
+ participant B
+ critical primary path
+ A->>B: Request
+ option fallback path
+ B-->>A: Error
+ end
+ `;
+
+ const first = parseMermaidByType(source);
+ expect(first.error).toBeUndefined();
+ expect(first.diagramType).toBe('sequence');
+
+ const exported = toMermaid(first.nodes, first.edges);
+ expect(exported).toContain('critical primary path');
+ expect(exported).toContain('option fallback path');
+
+ const second = parseMermaidByType(exported);
+ expect(second.error).toBeUndefined();
+ expect(second.diagramType).toBe('sequence');
+ expect(second.edges[0].data?.seqFragment).toMatchObject({
+ type: 'critical',
+ condition: 'primary path',
+ branchKind: 'start',
+ });
+ expect(second.edges[1].data?.seqFragment).toMatchObject({
+ type: 'critical',
+ condition: 'fallback path',
+ branchKind: 'option',
+ });
+ });
});
diff --git a/src/services/sequence/layoutConstants.ts b/src/services/sequence/layoutConstants.ts
new file mode 100644
index 00000000..a5af7d26
--- /dev/null
+++ b/src/services/sequence/layoutConstants.ts
@@ -0,0 +1,7 @@
+export const SEQ_BOX_H = 48;
+export const SEQ_ACTOR_EXTRA_H = 40;
+export const SEQ_LIFELINE_H = 500;
+export const SEQ_MSG_OFFSET = 20;
+export const SEQ_MSG_SPACING = 52;
+export const SEQ_NODE_W = 140;
+export const SEQ_LANE_GAP = 84;
diff --git a/src/services/sequenceLayout.test.ts b/src/services/sequenceLayout.test.ts
new file mode 100644
index 00000000..3869ea8b
--- /dev/null
+++ b/src/services/sequenceLayout.test.ts
@@ -0,0 +1,94 @@
+import { describe, expect, it } from 'vitest';
+import type { FlowEdge, FlowNode } from '@/lib/types';
+import { relayoutSequenceDiagram } from './sequenceLayout';
+
+function createParticipant(
+ id: string,
+ x: number,
+ kind: 'participant' | 'actor' = 'participant'
+): FlowNode {
+ return {
+ id,
+ type: 'sequence_participant',
+ position: { x, y: 120 },
+ data: {
+ label: id,
+ seqParticipantKind: kind,
+ },
+ } as FlowNode;
+}
+
+function createNote(id: string, target: string, order: number): FlowNode {
+ return {
+ id,
+ type: 'sequence_note',
+ position: { x: 0, y: 0 },
+ data: {
+ label: 'Shared context',
+ seqNoteTarget: target,
+ seqNotePosition: 'over',
+ seqMessageOrder: order,
+ },
+ } as FlowNode;
+}
+
+function createFragment(id: string, order: number): FlowNode {
+ return {
+ id,
+ type: 'annotation',
+ position: { x: 0, y: 0 },
+ data: {
+ label: 'ALT',
+ subLabel: 'happy path',
+ seqFragmentId: id,
+ seqMessageOrder: order,
+ },
+ } as FlowNode;
+}
+
+function createMessage(id: string, source: string, target: string, order: number): FlowEdge {
+ return {
+ id,
+ source,
+ target,
+ type: 'sequence_message',
+ data: {
+ seqMessageOrder: order,
+ },
+ } as FlowEdge;
+}
+
+describe('relayoutSequenceDiagram', () => {
+ it('repositions sequence participants into evenly spaced top lanes', () => {
+ const nodes = [
+ createParticipant('api', 240),
+ createParticipant('client', 0, 'actor'),
+ createParticipant('db', 480),
+ ];
+
+ const result = relayoutSequenceDiagram(nodes, []);
+ const participants = result.nodes.filter((node) => node.type === 'sequence_participant');
+
+ expect(participants.map((node) => node.position.y)).toEqual([0, 40, 40]);
+ expect(participants[0].position.x).toBeLessThan(participants[1].position.x);
+ expect(participants[1].position.x).toBeLessThan(participants[2].position.x);
+ });
+
+ it('keeps sequence notes and fragments aligned to message order timeline', () => {
+ const nodes = [
+ createParticipant('client', 0, 'actor'),
+ createParticipant('api', 220),
+ createNote('note-1', 'api', 2),
+ createFragment('fragment-1', 1),
+ ];
+ const edges = [createMessage('e-1', 'client', 'api', 1)];
+
+ const result = relayoutSequenceDiagram(nodes, edges);
+ const note = result.nodes.find((node) => node.id === 'note-1');
+ const fragment = result.nodes.find((node) => node.id === 'fragment-1');
+
+ expect(note?.position.y).toBeGreaterThan(150);
+ expect(fragment?.position.x).toBeLessThan(0);
+ expect(result.edges).toBe(edges);
+ });
+});
diff --git a/src/services/sequenceLayout.ts b/src/services/sequenceLayout.ts
new file mode 100644
index 00000000..e7af616d
--- /dev/null
+++ b/src/services/sequenceLayout.ts
@@ -0,0 +1,201 @@
+import { resolveNodeSize } from '@/components/nodeHelpers';
+import type { FlowEdge, FlowNode } from '@/lib/types';
+import { estimateWrappedTextBox } from '@/services/elk-layout/textSizing';
+import {
+ SEQ_ACTOR_EXTRA_H,
+ SEQ_BOX_H,
+ SEQ_LANE_GAP,
+ SEQ_MSG_OFFSET,
+ SEQ_MSG_SPACING,
+ SEQ_NODE_W,
+} from '@/services/sequence/layoutConstants';
+
+function sortByPosition(items: T[]): T[] {
+ return [...items].sort((left, right) => {
+ const leftOrder =
+ 'data' in left && typeof left.data?.seqMessageOrder === 'number' ? left.data.seqMessageOrder : null;
+ const rightOrder =
+ 'data' in right && typeof right.data?.seqMessageOrder === 'number' ? right.data.seqMessageOrder : null;
+
+ if (leftOrder !== null || rightOrder !== null) {
+ if (leftOrder === null) return 1;
+ if (rightOrder === null) return -1;
+ if (leftOrder !== rightOrder) return leftOrder - rightOrder;
+ }
+
+ const leftX = 'position' in left ? left.position.x : 0;
+ const rightX = 'position' in right ? right.position.x : 0;
+ if (leftX !== rightX) {
+ return leftX - rightX;
+ }
+
+ return left.id.localeCompare(right.id);
+ });
+}
+
+function getMeasuredNodeSize(node: FlowNode, minWidth: number, minHeight: number): { width: number; height: number } {
+ const measuredNode = node as FlowNode & { measured?: { width?: number; height?: number } };
+ const measuredWidth = measuredNode.measured?.width;
+ const measuredHeight = measuredNode.measured?.height;
+ if (typeof measuredWidth === 'number' && typeof measuredHeight === 'number') {
+ return {
+ width: Math.max(measuredWidth, minWidth),
+ height: Math.max(measuredHeight, minHeight),
+ };
+ }
+
+ const resolved = resolveNodeSize(node);
+ return {
+ width: Math.max(resolved.width, minWidth),
+ height: Math.max(resolved.height, minHeight),
+ };
+}
+
+function getSequenceTimelineY(order: number): number {
+ return SEQ_BOX_H + SEQ_ACTOR_EXTRA_H + SEQ_MSG_OFFSET + order * SEQ_MSG_SPACING;
+}
+
+function buildParticipantCenters(participants: FlowNode[]): Map {
+ const centers = new Map();
+ let currentLeft = 0;
+
+ for (const participant of participants) {
+ const size = getMeasuredNodeSize(participant, SEQ_NODE_W, SEQ_BOX_H + SEQ_ACTOR_EXTRA_H);
+ centers.set(participant.id, {
+ left: currentLeft,
+ center: currentLeft + size.width / 2,
+ width: size.width,
+ });
+ currentLeft += size.width + SEQ_LANE_GAP;
+ }
+
+ return centers;
+}
+
+function relayoutParticipants(
+ participants: FlowNode[],
+ centers: Map
+): FlowNode[] {
+ return participants.map((participant) => ({
+ ...participant,
+ position: {
+ x: centers.get(participant.id)?.left ?? participant.position.x,
+ y: participant.data?.seqParticipantKind === 'actor' ? 0 : SEQ_ACTOR_EXTRA_H,
+ },
+ }));
+}
+
+function relayoutNotes(
+ notes: FlowNode[],
+ centers: Map
+): FlowNode[] {
+ return sortByPosition(notes).map((note) => {
+ const order =
+ typeof note.data?.seqMessageOrder === 'number' ? note.data.seqMessageOrder : 0;
+ const noteSize = estimateWrappedTextBox(String(note.data?.label ?? ''), {
+ minWidth: 120,
+ minHeight: 56,
+ maxWidth: 180,
+ lineHeight: 18,
+ verticalPadding: 14,
+ });
+ const targetIds = Array.isArray(note.data?.seqNoteTargets)
+ ? note.data.seqNoteTargets.filter((targetId): targetId is string => typeof targetId === 'string')
+ : typeof note.data?.seqNoteTarget === 'string'
+ ? [note.data.seqNoteTarget]
+ : [];
+ const targetCenters = targetIds
+ .map((targetId) => centers.get(targetId))
+ .filter((value): value is { left: number; center: number; width: number } => Boolean(value));
+ const primaryCenter = targetCenters[0];
+ const sharedCenter =
+ targetCenters.length >= 2
+ ? (targetCenters[0].center + targetCenters[targetCenters.length - 1].center) / 2
+ : primaryCenter?.center;
+
+ let x = note.position.x;
+ if (note.data?.seqNotePosition === 'left' && primaryCenter) {
+ x = primaryCenter.left - noteSize.width - 32;
+ } else if (note.data?.seqNotePosition === 'right' && primaryCenter) {
+ x = primaryCenter.left + primaryCenter.width + 32;
+ } else if (typeof sharedCenter === 'number') {
+ x = sharedCenter - noteSize.width / 2;
+ }
+
+ return {
+ ...note,
+ position: {
+ x,
+ y: getSequenceTimelineY(order) - 18,
+ },
+ style: {
+ ...note.style,
+ width: noteSize.width,
+ minHeight: noteSize.height,
+ },
+ };
+ });
+}
+
+function relayoutFragments(
+ fragments: FlowNode[],
+ participants: FlowNode[]
+): FlowNode[] {
+ const leftEdge = participants[0]?.position.x ?? 0;
+
+ return sortByPosition(fragments).map((fragment, index) => {
+ const order =
+ typeof fragment.data?.seqMessageOrder === 'number' ? fragment.data.seqMessageOrder : index;
+ const fragmentSize = estimateWrappedTextBox(String(fragment.data?.subLabel ?? fragment.data?.label ?? ''), {
+ minWidth: 136,
+ minHeight: 54,
+ maxWidth: 196,
+ lineHeight: 18,
+ verticalPadding: 14,
+ });
+
+ return {
+ ...fragment,
+ position: {
+ x: leftEdge - fragmentSize.width - 36,
+ y: getSequenceTimelineY(order) - 28,
+ },
+ style: {
+ ...fragment.style,
+ width: fragmentSize.width,
+ minHeight: fragmentSize.height,
+ },
+ };
+ });
+}
+
+export function relayoutSequenceDiagram(
+ nodes: FlowNode[],
+ edges: FlowEdge[]
+): { nodes: FlowNode[]; edges: FlowEdge[] } {
+ const participants = sortByPosition(nodes.filter((node) => node.type === 'sequence_participant'));
+ if (participants.length === 0) {
+ return { nodes, edges };
+ }
+
+ const centers = buildParticipantCenters(participants);
+ const notes = nodes.filter((node) => node.type === 'sequence_note');
+ const fragments = nodes.filter(
+ (node) => node.type === 'annotation' && typeof node.data?.seqFragmentId === 'string'
+ );
+ const remainingNodes = nodes.filter(
+ (node) =>
+ node.type !== 'sequence_participant'
+ && node.type !== 'sequence_note'
+ && !(node.type === 'annotation' && typeof node.data?.seqFragmentId === 'string')
+ );
+
+ const layoutedParticipants = relayoutParticipants(participants, centers);
+ const layoutedNotes = relayoutNotes(notes, centers);
+ const layoutedFragments = relayoutFragments(fragments, layoutedParticipants);
+
+ return {
+ nodes: [...layoutedParticipants, ...layoutedNotes, ...layoutedFragments, ...remainingNodes],
+ edges,
+ };
+}
diff --git a/src/services/shapeLibrary/providerCatalog.ts b/src/services/shapeLibrary/providerCatalog.ts
index 3816bc5d..cac13b95 100644
--- a/src/services/shapeLibrary/providerCatalog.ts
+++ b/src/services/shapeLibrary/providerCatalog.ts
@@ -1,221 +1,247 @@
import type { DomainLibraryCategory, DomainLibraryItem } from '@/services/domainLibrary';
export interface ProviderShapePreview {
- packId: string;
- shapeId: string;
- label: string;
- category: string;
- previewUrl: string;
+ packId: string;
+ shapeId: string;
+ label: string;
+ category: string;
+ previewUrl: string;
}
interface SvgSource {
- provider: string;
- packId: string;
- shapeId: string;
- label: string;
- category: string;
- previewLoader: () => Promise;
+ provider: string;
+ packId: string;
+ shapeId: string;
+ label: string;
+ category: string;
+ previewLoader: () => Promise;
}
const svgModules = import.meta.glob('../../../assets/third-party-icons/*/processed/**/*.svg', {
- query: '?url',
- import: 'default',
+ query: '?url',
+ import: 'default',
}) as Record Promise>;
const providerCatalogPromiseCache = new Map>();
const shapePreviewCache = new Map();
const shapePreviewPromiseCache = new Map>();
-const KNOWN_PROVIDER_PACK_IDS: Partial> = {
- aws: 'aws-official-starter-v1',
- azure: 'azure-official-icons-v20',
- cncf: 'cncf-artwork-icons-v1',
- developer: 'developer-icons-v1',
+export const KNOWN_PROVIDER_PACK_IDS: Record = {
+ aws: 'aws-official-starter-v1',
+ azure: 'azure-official-icons-v20',
+ gcp: 'gcp-official-icons-v1',
+ cncf: 'cncf-artwork-icons-v1',
+ developer: 'developer-icons-v1',
};
function normalizeProviderPathSegment(value: string): string {
- return value.trim().toLowerCase();
+ return value.trim().toLowerCase();
}
function slugify(value: string): string {
- return value
- .trim()
- .toLowerCase()
- .replace(/[^a-z0-9]+/g, '-')
- .replace(/^-+|-+$/g, '');
+ return value
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '');
}
function inferLabelFromId(id: string): string {
- return id
- .split('-')
- .filter(Boolean)
- .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
- .join(' ');
+ return id
+ .split('-')
+ .filter(Boolean)
+ .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
+ .join(' ');
}
function getPackIdForProvider(provider: string): string {
- return KNOWN_PROVIDER_PACK_IDS[provider] ?? `${provider}-processed-pack-v1`;
+ return KNOWN_PROVIDER_PACK_IDS[provider] ?? `${provider}-processed-pack-v1`;
}
-function parseSvgSource(modulePath: string, previewLoader: () => Promise): SvgSource | null {
- const normalized = modulePath.replaceAll('\\', '/');
- const match = normalized.match(/assets\/third-party-icons\/([^/]+)\/processed\/(.+)\.svg$/);
+function getProviderColor(provider: string): string {
+ if (provider === 'aws') {
+ return 'amber';
+ }
- if (!match) {
- return null;
- }
+ if (provider === 'azure') {
+ return 'blue';
+ }
- const provider = normalizeProviderPathSegment(match[1]);
- const relativePath = match[2];
- const pathParts = relativePath.split('/');
- const category = pathParts.length > 1 ? inferLabelFromId(slugify(pathParts[0])) : 'Misc';
- const shapeId = slugify(relativePath.replaceAll('/', '-'));
+ if (provider === 'gcp') {
+ return 'emerald';
+ }
- return {
- provider,
- packId: getPackIdForProvider(provider),
- shapeId,
- label: inferLabelFromId(shapeId),
- category,
- previewLoader,
- };
+ if (provider === 'cncf') {
+ return 'cyan';
+ }
+
+ return 'slate';
}
-const SVG_SOURCES: SvgSource[] = Object.entries(svgModules)
- .map(([modulePath, previewLoader]) => parseSvgSource(modulePath, previewLoader))
- .filter((value): value is SvgSource => value !== null);
-
-function createProviderItem(
- provider: DomainLibraryCategory,
- source: SvgSource,
-): DomainLibraryItem {
- return {
- id: `${source.packId}:${source.shapeId}`,
- category: provider,
- label: source.label,
- description: `${provider.toUpperCase()} ${source.category}`,
- icon: 'Box',
- color: provider === 'aws'
- ? 'amber'
- : provider === 'azure'
- ? 'blue'
- : provider === 'gcp'
- ? 'emerald'
- : provider === 'cncf'
- ? 'cyan'
- : 'slate',
- nodeType: 'custom',
- assetPresentation: 'icon',
- providerShapeCategory: source.category,
- archIconPackId: source.packId,
- archIconShapeId: source.shapeId,
- };
+function parseSvgSource(
+ modulePath: string,
+ previewLoader: () => Promise
+): SvgSource | null {
+ const normalized = modulePath.replaceAll('\\', '/');
+ const match = normalized.match(/assets\/third-party-icons\/([^/]+)\/processed\/(.+)\.svg$/);
+
+ if (!match) {
+ return null;
+ }
+
+ const provider = normalizeProviderPathSegment(match[1]);
+ const relativePath = match[2];
+ const pathParts = relativePath.split('/');
+ const category = pathParts.length > 1 ? inferLabelFromId(slugify(pathParts[0])) : 'Misc';
+ const shapeId = slugify(relativePath.replaceAll('/', '-'));
+
+ return {
+ provider,
+ packId: getPackIdForProvider(provider),
+ shapeId,
+ label: inferLabelFromId(shapeId),
+ category,
+ previewLoader,
+ };
+}
+
+export const SVG_SOURCES: SvgSource[] = Object.entries(svgModules)
+ .map(([modulePath, previewLoader]) => parseSvgSource(modulePath, previewLoader))
+ .filter((value): value is SvgSource => value !== null);
+
+function createProviderItem(provider: DomainLibraryCategory, source: SvgSource): DomainLibraryItem {
+ return {
+ id: `${source.packId}:${source.shapeId}`,
+ category: provider,
+ label: source.label,
+ description: `${provider.toUpperCase()} ${source.category}`,
+ icon: 'Box',
+ color: getProviderColor(provider),
+ nodeType: 'custom',
+ assetPresentation: 'icon',
+ providerShapeCategory: source.category,
+ archIconPackId: source.packId,
+ archIconShapeId: source.shapeId,
+ };
}
export function listProviderCatalogProviders(): string[] {
- return Array.from(new Set(SVG_SOURCES.map((source) => source.provider))).sort((left, right) => left.localeCompare(right));
+ return Array.from(new Set(SVG_SOURCES.map((source) => source.provider))).sort((left, right) =>
+ left.localeCompare(right)
+ );
}
export function getProviderCatalogCount(provider: DomainLibraryCategory): number {
- const normalizedProvider = normalizeProviderPathSegment(provider);
- return SVG_SOURCES.filter((source) => source.provider === normalizedProvider).length;
+ const normalizedProvider = normalizeProviderPathSegment(provider);
+ return SVG_SOURCES.filter((source) => source.provider === normalizedProvider).length;
}
-export async function loadProviderCatalog(provider: DomainLibraryCategory): Promise {
- const normalizedProvider = normalizeProviderPathSegment(provider);
- const existingPromise = providerCatalogPromiseCache.get(normalizedProvider);
- if (existingPromise) {
- return existingPromise;
- }
-
- const catalogPromise = (async () => {
- return SVG_SOURCES
- .filter((source) => source.provider === normalizedProvider)
- .map((source) => createProviderItem(provider, source))
- .sort((left, right) => (
- left.providerShapeCategory === right.providerShapeCategory
- ? left.label.localeCompare(right.label)
- : (left.providerShapeCategory || '').localeCompare(right.providerShapeCategory || '')
- ));
- })();
-
- providerCatalogPromiseCache.set(normalizedProvider, catalogPromise);
- return catalogPromise;
+export async function loadProviderCatalog(
+ provider: DomainLibraryCategory
+): Promise {
+ const normalizedProvider = normalizeProviderPathSegment(provider);
+ const existingPromise = providerCatalogPromiseCache.get(normalizedProvider);
+ if (existingPromise) {
+ return existingPromise;
+ }
+
+ const catalogPromise = (async () => {
+ return SVG_SOURCES.filter((source) => source.provider === normalizedProvider)
+ .map((source) => createProviderItem(provider, source))
+ .sort((left, right) =>
+ left.providerShapeCategory === right.providerShapeCategory
+ ? left.label.localeCompare(right.label)
+ : (left.providerShapeCategory || '').localeCompare(right.providerShapeCategory || '')
+ );
+ })();
+
+ providerCatalogPromiseCache.set(normalizedProvider, catalogPromise);
+ return catalogPromise;
}
interface LoadProviderCatalogSuggestionsOptions {
- category?: string;
- excludeShapeId?: string;
- limit?: number;
- query?: string;
+ category?: string;
+ excludeShapeId?: string;
+ limit?: number;
+ query?: string;
}
export async function loadProviderCatalogSuggestions(
- provider: DomainLibraryCategory,
- options: LoadProviderCatalogSuggestionsOptions = {},
+ provider: DomainLibraryCategory,
+ options: LoadProviderCatalogSuggestionsOptions = {}
): Promise {
- const items = await loadProviderCatalog(provider);
- const normalizedQuery = options.query?.trim().toLowerCase() ?? '';
- const filtered = items.filter((item) => {
- if (options.excludeShapeId && item.archIconShapeId === options.excludeShapeId) {
- return false;
- }
- if (options.category && item.providerShapeCategory !== options.category) {
- return false;
- }
- if (!normalizedQuery) {
- return true;
- }
- return item.label.toLowerCase().includes(normalizedQuery)
- || item.description.toLowerCase().includes(normalizedQuery)
- || (item.providerShapeCategory || '').toLowerCase().includes(normalizedQuery);
- });
-
- const pool = filtered.length > 0 || !options.category
- ? filtered
- : items.filter((item) => (
- (!options.excludeShapeId || item.archIconShapeId !== options.excludeShapeId)
- && (!normalizedQuery
- || item.label.toLowerCase().includes(normalizedQuery)
- || item.description.toLowerCase().includes(normalizedQuery)
- || (item.providerShapeCategory || '').toLowerCase().includes(normalizedQuery))
- ));
-
- return pool.slice(0, options.limit ?? 8);
-}
-
-export async function loadProviderShapePreview(packId: string, shapeId: string): Promise {
- const cacheKey = `${packId}:${shapeId}`;
- const cachedPreview = shapePreviewCache.get(cacheKey);
- if (cachedPreview) {
- return cachedPreview;
+ const items = await loadProviderCatalog(provider);
+ const normalizedQuery = options.query?.trim().toLowerCase() ?? '';
+ const filtered = items.filter((item) => {
+ if (options.excludeShapeId && item.archIconShapeId === options.excludeShapeId) {
+ return false;
}
- const cachedPromise = shapePreviewPromiseCache.get(cacheKey);
- if (cachedPromise) {
- return cachedPromise;
+ if (options.category && item.providerShapeCategory !== options.category) {
+ return false;
}
-
- const source = SVG_SOURCES.find((candidate) => candidate.packId === packId && candidate.shapeId === shapeId);
- if (!source) {
- return null;
+ if (!normalizedQuery) {
+ return true;
}
+ return (
+ item.label.toLowerCase().includes(normalizedQuery) ||
+ item.description.toLowerCase().includes(normalizedQuery) ||
+ (item.providerShapeCategory || '').toLowerCase().includes(normalizedQuery)
+ );
+ });
+
+ const pool =
+ filtered.length > 0 || !options.category
+ ? filtered
+ : items.filter(
+ (item) =>
+ (!options.excludeShapeId || item.archIconShapeId !== options.excludeShapeId) &&
+ (!normalizedQuery ||
+ item.label.toLowerCase().includes(normalizedQuery) ||
+ item.description.toLowerCase().includes(normalizedQuery) ||
+ (item.providerShapeCategory || '').toLowerCase().includes(normalizedQuery))
+ );
+
+ return pool.slice(0, options.limit ?? 8);
+}
+
+export async function loadProviderShapePreview(
+ packId: string,
+ shapeId: string
+): Promise {
+ const cacheKey = `${packId}:${shapeId}`;
+ const cachedPreview = shapePreviewCache.get(cacheKey);
+ if (cachedPreview) {
+ return cachedPreview;
+ }
+ const cachedPromise = shapePreviewPromiseCache.get(cacheKey);
+ if (cachedPromise) {
+ return cachedPromise;
+ }
+
+ const source = SVG_SOURCES.find(
+ (candidate) => candidate.packId === packId && candidate.shapeId === shapeId
+ );
+ if (!source) {
+ return null;
+ }
+
+ const previewPromise = source
+ .previewLoader()
+ .then((previewUrl) => {
+ const preview = {
+ packId,
+ shapeId,
+ label: source.label,
+ category: source.category,
+ previewUrl,
+ };
+ shapePreviewCache.set(cacheKey, preview);
+ shapePreviewPromiseCache.delete(cacheKey);
+ return preview;
+ })
+ .catch((error) => {
+ shapePreviewPromiseCache.delete(cacheKey);
+ throw error;
+ });
- const previewPromise = source.previewLoader()
- .then((previewUrl) => {
- const preview = {
- packId,
- shapeId,
- label: source.label,
- category: source.category,
- previewUrl,
- };
- shapePreviewCache.set(cacheKey, preview);
- shapePreviewPromiseCache.delete(cacheKey);
- return preview;
- })
- .catch((error) => {
- shapePreviewPromiseCache.delete(cacheKey);
- throw error;
- });
-
- shapePreviewPromiseCache.set(cacheKey, previewPromise);
- return previewPromise;
+ shapePreviewPromiseCache.set(cacheKey, previewPromise);
+ return previewPromise;
}
diff --git a/src/services/smartEdgeRouting.ts b/src/services/smartEdgeRouting.ts
index 0d03eba3..2d3b7371 100644
--- a/src/services/smartEdgeRouting.ts
+++ b/src/services/smartEdgeRouting.ts
@@ -3,24 +3,13 @@ import { NODE_WIDTH, NODE_HEIGHT } from '../constants';
import type { ViewSettings } from '@/store/types';
import { getNodeParentId } from '@/lib/nodeParent';
import { getNodeHandleIdForSide, type HandleSide } from '@/lib/nodeHandles';
+import { resolveNodeSize } from '@/components/nodeHelpers';
+import { estimateWrappedTextBox, DEFAULT_MAX_WIDTH } from './elk-layout/textSizing';
-/**
- * Assign intelligent `sourceHandle` and `targetHandle` to each edge
- * based on the relative positions of connected nodes.
- *
- * This produces natural edge routing:
- * - If target is below source โ bottomโtop
- * - If target is right of source โ rightโleft
- * - Bidirectional edges use different handle pairs
- * - Multiple edges between same pair distribute across handles
- */
-// Helper to get absolute position
-function getAbsolutePosition(node: FlowNode, nodeMap: Map): { x: number, y: number } {
- // We intentionally ignore node.positionAbsolute here because during drag operations
- // in React Flow, the positionAbsolute might be stale or not yet updated in the store
- // while node.position (relative) is updated.
- // To ensure smooth "magnetic" routing, we always calculate absolute position
- // from the hierarchy.
+// Walks the parent hierarchy to get the canvas-absolute position of a node.
+// Uses node.position (relative) rather than positionAbsolute, which can be
+// stale during drag operations before React Flow updates the store.
+function getAbsolutePosition(node: FlowNode, nodeMap: Map): { x: number; y: number } {
let x = node.position.x;
let y = node.position.y;
@@ -39,33 +28,25 @@ function getAbsolutePosition(node: FlowNode, nodeMap: Map): {
return { x, y };
}
-// Helper to get node dimensions robustly
-function getNodeDimensions(node: FlowNode): { width: number, height: number } {
- const measured = (node as FlowNode & {
- measured?: {
- width?: number;
- height?: number;
- };
- }).measured;
- if (measured && measured.width && measured.height) {
+function getNodeDimensions(node: FlowNode): { width: number; height: number } {
+ const measured = (node as FlowNode & { measured?: { width?: number; height?: number } }).measured;
+ if (measured?.width && measured?.height) {
return { width: measured.width, height: measured.height };
}
- const styleWidth = node.style?.width;
- const styleHeight = node.style?.height;
-
- const w = typeof styleWidth === 'string' && styleWidth.endsWith('px')
- ? parseFloat(styleWidth)
- : (typeof styleWidth === 'number' ? styleWidth : null);
-
- const h = typeof styleHeight === 'string' && styleHeight.endsWith('px')
- ? parseFloat(styleHeight)
- : (typeof styleHeight === 'number' ? styleHeight : null);
+ const resolved = resolveNodeSize(node);
+ if (resolved.width && resolved.height) {
+ return resolved;
+ }
- return {
- width: w ?? node.width ?? NODE_WIDTH,
- height: h ?? node.height ?? NODE_HEIGHT
- };
+ // Match ELK's text-based estimation so handle assignment uses the same
+ // assumed size that ELK used when computing node positions.
+ const estimate = estimateWrappedTextBox(String(node.data?.label ?? ''), {
+ minWidth: NODE_WIDTH,
+ minHeight: NODE_HEIGHT,
+ maxWidth: DEFAULT_MAX_WIDTH,
+ });
+ return { width: estimate.width, height: estimate.height };
}
type RoutingContext = {
@@ -120,27 +101,22 @@ function preserveEdgeLabelPlacement(originalEdge: FlowEdge, nextEdge: FlowEdge):
};
}
+export function handleSideFromVector(dx: number, dy: number): HandleSide {
+ if (Math.abs(dx) >= Math.abs(dy)) return dx >= 0 ? 'right' : 'left';
+ return dy >= 0 ? 'bottom' : 'top';
+}
+
function resolveAutoHandleSides(
dx: number,
dy: number,
profile: SmartRoutingOptions['profile']
): { sourceHandleSide: HandleSide; targetHandleSide: HandleSide } {
- const verticalDominance = profile === 'infrastructure'
- ? Math.abs(dy) > Math.abs(dx) * 1.25
- : Math.abs(dy) >= Math.abs(dx);
-
- if (verticalDominance) {
- if (dy >= 0) {
- return { sourceHandleSide: 'bottom', targetHandleSide: 'top' };
- }
- return { sourceHandleSide: 'top', targetHandleSide: 'bottom' };
- }
-
- if (dx >= 0) {
- return { sourceHandleSide: 'right', targetHandleSide: 'left' };
- }
-
- return { sourceHandleSide: 'left', targetHandleSide: 'right' };
+ // Infrastructure uses a 1.25x bias toward horizontal routing by
+ // reducing the vertical component before side selection.
+ const effectiveDy = profile === 'infrastructure' ? dy / 1.25 : dy;
+ const sourceSide = handleSideFromVector(dx, effectiveDy);
+ const targetSide = handleSideFromVector(-dx, -effectiveDy);
+ return { sourceHandleSide: sourceSide, targetHandleSide: targetSide };
}
function buildRoutingContext(nodes: FlowNode[], _edges: FlowEdge[]): RoutingContext {
diff --git a/src/services/stateDiagramRoundTrip.test.ts b/src/services/stateDiagramRoundTrip.test.ts
index 16d9d270..963820b2 100644
--- a/src/services/stateDiagramRoundTrip.test.ts
+++ b/src/services/stateDiagramRoundTrip.test.ts
@@ -51,4 +51,84 @@ describe('stateDiagram round-trip', () => {
const busyNode = second.nodes.find((node) => node.id === 'Busy');
expect(busyNode?.parentId).toBe('Working');
});
+
+ it('preserves notes and control states through parse/export/parse', () => {
+ const source = `
+ stateDiagram-v2
+ state FanOut <>
+ state FanIn <>
+ [*] --> FanOut
+ FanOut --> Idle
+ Idle --> FanIn
+ note right of Idle: Waiting for input
+ FanIn --> [*]
+ `;
+
+ const first = parseMermaidByType(source);
+ expect(first.error).toBeUndefined();
+ expect(first.nodes.some((node) => node.data.stateControlKind === 'fork')).toBe(true);
+ expect(first.nodes.some((node) => node.data.stateControlKind === 'join')).toBe(true);
+ expect(first.nodes.some((node) => node.data.stateNoteTarget === 'Idle')).toBe(true);
+
+ const exported = toMermaid(first.nodes, first.edges);
+ expect(exported).toContain('state FanOut <>');
+ expect(exported).toContain('state FanIn <>');
+ expect(exported).toContain('note right of Idle: Waiting for input');
+
+ const second = parseMermaidByType(exported);
+ expect(second.error).toBeUndefined();
+ expect(second.nodes.some((node) => node.data.stateControlKind === 'fork')).toBe(true);
+ expect(second.nodes.some((node) => node.data.stateControlKind === 'join')).toBe(true);
+ expect(second.nodes.some((node) => node.data.stateNoteTarget === 'Idle')).toBe(true);
+ });
+
+ it('preserves composite state labels with aliases through parse/export/parse', () => {
+ const source = `
+ stateDiagram-v2
+ state "Working Set" as WorkingSet {
+ [*] --> Busy
+ Busy --> Idle
+ }
+ note left of WorkingSet: Parent note
+ Idle --> [*]
+ `;
+
+ const first = parseMermaidByType(source);
+ expect(first.error).toBeUndefined();
+ expect(first.diagramType).toBe('stateDiagram');
+ expect(first.nodes.find((node) => node.id === 'WorkingSet')?.data.label).toBe('Working Set');
+
+ const exported = toMermaid(first.nodes, first.edges);
+ expect(exported).toContain('state "Working Set" as WorkingSet {');
+ expect(exported).toContain('note left of WorkingSet: Parent note');
+
+ const second = parseMermaidByType(exported);
+ expect(second.error).toBeUndefined();
+ expect(second.diagramType).toBe('stateDiagram');
+ expect(second.nodes.find((node) => node.id === 'WorkingSet')?.data.label).toBe('Working Set');
+ expect(second.nodes.some((node) => node.data.stateNoteTarget === 'WorkingSet')).toBe(true);
+ });
+
+ it('preserves explicit direction through parse/export/parse when provided', () => {
+ const source = `
+ stateDiagram-v2
+ direction LR
+ [*] --> Idle
+ Idle --> Running
+ Running --> [*]
+ `;
+
+ const first = parseMermaidByType(source);
+ expect(first.error).toBeUndefined();
+ expect(first.diagramType).toBe('stateDiagram');
+ expect(first.direction).toBe('LR');
+
+ const exported = toMermaid(first.nodes, first.edges, first.direction);
+ expect(exported).toContain('direction LR');
+
+ const second = parseMermaidByType(exported);
+ expect(second.error).toBeUndefined();
+ expect(second.diagramType).toBe('stateDiagram');
+ expect(second.direction).toBe('LR');
+ });
});
diff --git a/src/services/templateLibrary/templateFactories.ts b/src/services/templateLibrary/templateFactories.ts
index 03ac737a..f734568b 100644
--- a/src/services/templateLibrary/templateFactories.ts
+++ b/src/services/templateLibrary/templateFactories.ts
@@ -1,4 +1,5 @@
import { createDefaultEdge } from '@/constants';
+import { createProviderIconData } from '@/lib/nodeIconState';
import { NodeType, type FlowNode, type NodeData } from '@/lib/types';
import type { TemplateManifest } from './types';
@@ -51,10 +52,12 @@ export function createAssetNode(
color: 'custom',
customColor: '#ffffff',
assetPresentation: 'icon',
- assetProvider: provider,
- assetCategory: category,
- archIconPackId: PROVIDER_PACK_IDS[provider],
- archIconShapeId: shapeId,
+ ...createProviderIconData({
+ packId: PROVIDER_PACK_IDS[provider],
+ shapeId,
+ provider,
+ category,
+ }),
},
};
}
diff --git a/src/store.test.ts b/src/store.test.ts
index 989edcfb..198d7133 100644
--- a/src/store.test.ts
+++ b/src/store.test.ts
@@ -373,6 +373,10 @@ describe('flow store Mermaid diagnostics contract', () => {
useFlowStore.getState().setMermaidDiagnostics({
source: 'paste',
diagramType: 'flowchart',
+ importState: 'editable_partial',
+ statusLabel: 'Ready with warnings',
+ statusDetail: '1 nodes, 0 edges, partial editability',
+ originalSource: 'flowchart TD\nA-->B',
diagnostics: [
{
message: 'Sample diagnostic',
@@ -386,6 +390,9 @@ describe('flow store Mermaid diagnostics contract', () => {
let state = useFlowStore.getState();
expect(state.mermaidDiagnostics).toBeTruthy();
expect(state.mermaidDiagnostics?.source).toBe('paste');
+ expect(state.mermaidDiagnostics?.importState).toBe('editable_partial');
+ expect(state.mermaidDiagnostics?.statusLabel).toBe('Ready with warnings');
+ expect(state.mermaidDiagnostics?.originalSource).toContain('flowchart TD');
expect(state.mermaidDiagnostics?.diagnostics).toHaveLength(1);
expect(state.mermaidDiagnostics?.error).toBe('Sample error');
diff --git a/src/store/types.ts b/src/store/types.ts
index 7d2f164a..c5404ba3 100644
--- a/src/store/types.ts
+++ b/src/store/types.ts
@@ -14,6 +14,7 @@ import type {
FlowTab,
GlobalEdgeOptions,
} from '@/lib/types';
+import type { MermaidImportStatus } from '@/services/mermaid/importContracts';
import type { ExportSerializationMode } from '@/services/canonicalSerialization';
import type { FlowDocument } from '@/services/storage/flowDocumentModel';
@@ -73,6 +74,10 @@ export interface Layer {
export interface MermaidDiagnosticsSnapshot {
source: 'paste' | 'import' | 'code';
diagramType?: DiagramType;
+ importState?: MermaidImportStatus;
+ statusLabel?: string;
+ statusDetail?: string;
+ originalSource?: string;
diagnostics: ParseDiagnostic[];
error?: string;
updatedAt: number;
diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo
index a440610e..f4810c49 100644
--- a/tsconfig.tsbuildinfo
+++ b/tsconfig.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/app.test.tsx","./src/app.tsx","./src/constants.test.ts","./src/constants.ts","./src/index.tsx","./src/store.test.ts","./src/store.ts","./src/theme.test.ts","./src/theme.ts","./src/app/routestate.test.ts","./src/app/routestate.ts","./src/components/annotationnode.tsx","./src/components/architecturerulespanel.tsx","./src/components/cinematicexportoverlay.tsx","./src/components/commandbar.test.tsx","./src/components/commandbar.tsx","./src/components/connectmenu.test.tsx","./src/components/connectmenu.tsx","./src/components/connectmenusections.tsx","./src/components/contextmenu.test.tsx","./src/components/contextmenu.tsx","./src/components/customconnectionline.test.tsx","./src/components/customconnectionline.tsx","./src/components/customedge.tsx","./src/components/customnode.handleinteraction.test.tsx","./src/components/customnode.tsx","./src/components/customnodecontent.tsx","./src/components/designsystem.integration.test.tsx","./src/components/diagramviewer.tsx","./src/components/errorboundary.tsx","./src/components/exportmenu.tsx","./src/components/exportmenupanel.test.tsx","./src/components/exportmenupanel.tsx","./src/components/flowcanvas.tsx","./src/components/floweditor.tsx","./src/components/floweditoremptystate.tsx","./src/components/floweditorlayoutoverlay.tsx","./src/components/floweditorpanels.test.tsx","./src/components/floweditorpanels.tsx","./src/components/flowtabs.test.tsx","./src/components/flowtabs.tsx","./src/components/groupnode.tsx","./src/components/homepage.integration.test.tsx","./src/components/homepage.tsx","./src/components/iconassetnodebody.tsx","./src/components/iconmap.test.tsx","./src/components/iconmap.ts","./src/components/imagenode.tsx","./src/components/importrecoverydialog.test.tsx","./src/components/importrecoverydialog.tsx","./src/components/inlinetexteditsurface.test.tsx","./src/components/inlinetexteditsurface.tsx","./src/components/keyboardshortcutsmodal.tsx","./src/components/languageselector.tsx","./src/components/markdownrenderer.tsx","./src/components/memoizedmarkdown.tsx","./src/components/navigationcontrols.tsx","./src/components/nodebadges.tsx","./src/components/nodechrome.tsx","./src/components/nodequickcreatebuttons.tsx","./src/components/nodeshapesvg.tsx","./src/components/nodetransformcontrols.tsx","./src/components/playbackcontrols.tsx","./src/components/propertiespanel.tsx","./src/components/rightrail.tsx","./src/components/sectionnode.tsx","./src/components/shareembedmodal.tsx","./src/components/sharemodal.test.tsx","./src/components/sharemodal.tsx","./src/components/sidebarshell.tsx","./src/components/snapshotspanel.test.tsx","./src/components/snapshotspanel.tsx","./src/components/studioaipanel.test.tsx","./src/components/studioaipanel.tsx","./src/components/studioaipanelsections.tsx","./src/components/studiocodepanel.test.tsx","./src/components/studiocodepanel.tsx","./src/components/studiopanel.test.tsx","./src/components/studiopanel.tsx","./src/components/studioplaybackpanel.test.tsx","./src/components/studioplaybackpanel.tsx","./src/components/swimlanenode.tsx","./src/components/textnode.tsx","./src/components/themetoggle.tsx","./src/components/toolbar.tsx","./src/components/tooltip.tsx","./src/components/topnav.tsx","./src/components/welcomemodal.tsx","./src/components/annotationtheme.ts","./src/components/container-nodes.handleinteraction.test.tsx","./src/components/editorsurfacetiers.ts","./src/components/handleinteraction.test.ts","./src/components/handleinteraction.ts","./src/components/handleinteractionusage.test.ts","./src/components/lightweight-nodes.handleinteraction.test.tsx","./src/components/markdownsyntax.ts","./src/components/nodehelpers.test.ts","./src/components/nodehelpers.ts","./src/components/sharemodalcontent.ts","./src/components/studioaicopy.ts","./src/components/studioaipanelexamples.ts","./src/components/transformdiagnostics.test.ts","./src/components/transformdiagnostics.ts","./src/components/useactivenodeselection.ts","./src/components/useexportmenu.test.tsx","./src/components/useexportmenu.ts","./src/components/settingsmodal/aisettings.tsx","./src/components/settingsmodal/canvassettings.tsx","./src/components/settingsmodal/generalsettings.test.tsx","./src/components/settingsmodal/generalsettings.tsx","./src/components/settingsmodal/settingsmodal.test.tsx","./src/components/settingsmodal/settingsmodal.tsx","./src/components/settingsmodal/shortcutssettings.tsx","./src/components/settingsmodal/ai/advancedendpointsection.tsx","./src/components/settingsmodal/ai/customheaderseditor.tsx","./src/components/settingsmodal/ai/privacysection.tsx","./src/components/settingsmodal/ai/providericon.tsx","./src/components/settingsmodal/ai/providersection.tsx","./src/components/settingsmodal/ai/step.tsx","./src/components/add-items/additemregistry.tsx","./src/components/app/docssiteredirect.test.tsx","./src/components/app/docssiteredirect.tsx","./src/components/app/mobileworkspacegate.test.tsx","./src/components/app/mobileworkspacegate.tsx","./src/components/app/routeloadingfallback.test.tsx","./src/components/app/routeloadingfallback.tsx","./src/components/app/mobileworkspacegatecopy.ts","./src/components/app/routeloadingcopy.ts","./src/components/architecture-lint/lintrulespanel.tsx","./src/components/architecture-lint/ruleform.tsx","./src/components/architecture-lint/visualeditor.tsx","./src/components/command-bar/assetsview.test.tsx","./src/components/command-bar/assetsview.tsx","./src/components/command-bar/codebaseimportpanels.tsx","./src/components/command-bar/codebaseimportsection.tsx","./src/components/command-bar/designsystemeditor.tsx","./src/components/command-bar/designsystemview.tsx","./src/components/command-bar/diagramminipreview.tsx","./src/components/command-bar/figmaimportpanel.tsx","./src/components/command-bar/importsurfaceprimitives.tsx","./src/components/command-bar/importview.tsx","./src/components/command-bar/importviewpanels.tsx","./src/components/command-bar/layersview.tsx","./src/components/command-bar/layoutview.tsx","./src/components/command-bar/pagesview.tsx","./src/components/command-bar/rootview.test.tsx","./src/components/command-bar/rootview.tsx","./src/components/command-bar/searchview.tsx","./src/components/command-bar/templatesview.test.tsx","./src/components/command-bar/templatesview.tsx","./src/components/command-bar/viewheader.tsx","./src/components/command-bar/visualsview.tsx","./src/components/command-bar/applycodechanges.ts","./src/components/command-bar/assetsviewconstants.ts","./src/components/command-bar/importdetection.test.ts","./src/components/command-bar/importdetection.ts","./src/components/command-bar/importnativeparsers.ts","./src/components/command-bar/importviewmodel.test.ts","./src/components/command-bar/importviewmodel.ts","./src/components/command-bar/mermaidimportparser.ts","./src/components/command-bar/searchquery.test.ts","./src/components/command-bar/searchquery.ts","./src/components/command-bar/types.ts","./src/components/command-bar/useaiviewstate.test.tsx","./src/components/command-bar/useaiviewstate.ts","./src/components/command-bar/usecloudassetcatalog.ts","./src/components/command-bar/usecommandbarcommands.test.tsx","./src/components/command-bar/usecommandbarcommands.tsx","./src/components/custom-edge/customedgewrapper.tsx","./src/components/custom-edge/edgemarkerdefs.tsx","./src/components/custom-edge/sequencemessageedge.test.tsx","./src/components/custom-edge/sequencemessageedge.tsx","./src/components/custom-edge/animatededgepresentation.test.ts","./src/components/custom-edge/animatededgepresentation.ts","./src/components/custom-edge/classrelationsemantics.test.ts","./src/components/custom-edge/classrelationsemantics.ts","./src/components/custom-edge/edgerendermode.ts","./src/components/custom-edge/pathutils.test.ts","./src/components/custom-edge/pathutils.ts","./src/components/custom-edge/pathutilsgeometry.ts","./src/components/custom-edge/pathutilssiblingrouting.ts","./src/components/custom-edge/pathutilstypes.ts","./src/components/custom-edge/relationroutingsemantics.test.ts","./src/components/custom-edge/relationroutingsemantics.ts","./src/components/custom-edge/standardedgemarkers.test.ts","./src/components/custom-edge/standardedgemarkers.ts","./src/components/custom-nodes/architecturenode.handleinteraction.test.tsx","./src/components/custom-nodes/architecturenode.tsx","./src/components/custom-nodes/browsernode.tsx","./src/components/custom-nodes/classentitynode.handleinteraction.test.tsx","./src/components/custom-nodes/classnode.tsx","./src/components/custom-nodes/entitynode.tsx","./src/components/custom-nodes/journeynode.tsx","./src/components/custom-nodes/mindmapnode.tsx","./src/components/custom-nodes/mobilenode.tsx","./src/components/custom-nodes/sequencenotenode.tsx","./src/components/custom-nodes/sequenceparticipantnode.tsx","./src/components/custom-nodes/structurednodehandles.tsx","./src/components/custom-nodes/visualnodes.handleinteraction.test.tsx","./src/components/custom-nodes/browservariantrenderer.tsx","./src/components/custom-nodes/mobilevariantrenderer.tsx","./src/components/custom-nodes/structuredlistnavigation.test.ts","./src/components/custom-nodes/structuredlistnavigation.ts","./src/components/custom-nodes/variantconstants.ts","./src/components/diagram-diff/diffmodebanner.tsx","./src/components/flow-canvas/flowcanvasalignmentguidesoverlay.tsx","./src/components/flow-canvas/flowcanvasoverlays.tsx","./src/components/flow-canvas/flowcanvassurface.test.tsx","./src/components/flow-canvas/flowcanvassurface.tsx","./src/components/flow-canvas/streamingoverlay.tsx","./src/components/flow-canvas/alignmentguides.test.ts","./src/components/flow-canvas/alignmentguides.ts","./src/components/flow-canvas/flowcanvasreactflowcontracts.ts","./src/components/flow-canvas/flowcanvastypes.test.ts","./src/components/flow-canvas/flowcanvastypes.tsx","./src/components/flow-canvas/largegraphsafetymode.test.ts","./src/components/flow-canvas/largegraphsafetymode.ts","./src/components/flow-canvas/nodechromecoverage.test.ts","./src/components/flow-canvas/pastehelpers.test.ts","./src/components/flow-canvas/pastehelpers.ts","./src/components/flow-canvas/useflowcanvasalignmentguides.ts","./src/components/flow-canvas/useflowcanvasconnectionstate.ts","./src/components/flow-canvas/useflowcanvascontextactions.ts","./src/components/flow-canvas/useflowcanvasdragdrop.ts","./src/components/flow-canvas/useflowcanvasinteractionlod.ts","./src/components/flow-canvas/useflowcanvasmenus.ts","./src/components/flow-canvas/useflowcanvasmenusandactions.test.tsx","./src/components/flow-canvas/useflowcanvasmenusandactions.ts","./src/components/flow-canvas/useflowcanvasnodedraghandlers.ts","./src/components/flow-canvas/useflowcanvaspaste.ts","./src/components/flow-canvas/useflowcanvasreactflowconfig.test.ts","./src/components/flow-canvas/useflowcanvasreactflowconfig.ts","./src/components/flow-canvas/useflowcanvasselectiontools.test.tsx","./src/components/flow-canvas/useflowcanvasselectiontools.ts","./src/components/flow-canvas/useflowcanvasviewstate.test.ts","./src/components/flow-canvas/useflowcanvasviewstate.ts","./src/components/flow-canvas/useflowcanvaszoomlod.ts","./src/components/flow-editor/collaborationpresenceoverlay.test.tsx","./src/components/flow-editor/collaborationpresenceoverlay.tsx","./src/components/flow-editor/floweditorchrome.tsx","./src/components/flow-editor/buildfloweditorcontrollerparams.ts","./src/components/flow-editor/buildfloweditorscreencontrollerparams.ts","./src/components/flow-editor/chromeproptypes.ts","./src/components/flow-editor/floweditorchromeprops.ts","./src/components/flow-editor/infradslapply.test.ts","./src/components/flow-editor/infradslapply.ts","./src/components/flow-editor/panelprops.test.ts","./src/components/flow-editor/panelprops.ts","./src/components/flow-editor/shouldexitstudioonselection.test.ts","./src/components/flow-editor/shouldexitstudioonselection.ts","./src/components/flow-editor/usecollaborationnodepositions.ts","./src/components/flow-editor/usefloweditorcontroller.ts","./src/components/flow-editor/usefloweditorinteractionbindings.ts","./src/components/flow-editor/usefloweditorpanelactions.ts","./src/components/flow-editor/usefloweditorpanelprops.ts","./src/components/flow-editor/usefloweditorruntime.ts","./src/components/flow-editor/usefloweditorscreenbehavior.ts","./src/components/flow-editor/usefloweditorscreenmodel.ts","./src/components/flow-editor/usefloweditorscreenstate.ts","./src/components/flow-editor/usefloweditorshellcontroller.test.tsx","./src/components/flow-editor/usefloweditorshellcontroller.ts","./src/components/flow-editor/usefloweditorstudiocontroller.test.tsx","./src/components/flow-editor/usefloweditorstudiocontroller.ts","./src/components/flow-editor/useinfradslapply.ts","./src/components/home/githubcard.tsx","./src/components/home/homedashboard.tsx","./src/components/home/homeflowdialogs.tsx","./src/components/home/homesettingsview.tsx","./src/components/home/homesidebar.tsx","./src/components/home/hometemplatesview.tsx","./src/components/home/sidebarfooter.tsx","./src/components/home/welcomemodalstate.ts","./src/components/icons/assetsicon.tsx","./src/components/icons/openflowlogo.tsx","./src/components/infra-sync/infrasyncpanel.test.tsx","./src/components/infra-sync/infrasyncpanel.tsx","./src/components/journey/journeyscorecontrol.test.tsx","./src/components/journey/journeyscorecontrol.tsx","./src/components/properties/bulknodeproperties.test.tsx","./src/components/properties/bulknodeproperties.tsx","./src/components/properties/bulknodepropertiesfamilysections.tsx","./src/components/properties/bulknodepropertiessections.tsx","./src/components/properties/bulknodepropertiesutilitysections.tsx","./src/components/properties/colorpicker.test.tsx","./src/components/properties/colorpicker.tsx","./src/components/properties/customcolorpopover.tsx","./src/components/properties/diagramnodepropertiesrouter.test.ts","./src/components/properties/diagramnodepropertiesrouter.tsx","./src/components/properties/edgeproperties.tsx","./src/components/properties/iconpicker.tsx","./src/components/properties/icontilepickerprimitives.tsx","./src/components/properties/imageupload.tsx","./src/components/properties/inspectorprimitives.tsx","./src/components/properties/nodeactionbuttons.tsx","./src/components/properties/nodecontentsection.tsx","./src/components/properties/nodeimagesettingssection.tsx","./src/components/properties/nodeproperties.test.tsx","./src/components/properties/nodeproperties.tsx","./src/components/properties/nodewireframevariantsection.tsx","./src/components/properties/propertysliderrow.tsx","./src/components/properties/segmentedchoice.tsx","./src/components/properties/shapeselector.tsx","./src/components/properties/swatchpicker.tsx","./src/components/properties/bulknodepropertiesmodel.test.ts","./src/components/properties/bulknodepropertiesmodel.ts","./src/components/properties/colorpickerutils.ts","./src/components/properties/propertyinputbehavior.test.ts","./src/components/properties/propertyinputbehavior.ts","./src/components/properties/sectionactionbuilder.ts","./src/components/properties/shared.ts","./src/components/properties/wireframevariants.ts","./src/components/properties/edge/architectureedgesemanticssection.tsx","./src/components/properties/edge/edgecolorsection.test.tsx","./src/components/properties/edge/edgecolorsection.tsx","./src/components/properties/edge/edgeconditionsection.tsx","./src/components/properties/edge/edgelabelsection.tsx","./src/components/properties/edge/edgerelationsection.test.tsx","./src/components/properties/edge/edgerelationsection.tsx","./src/components/properties/edge/edgeroutesection.test.tsx","./src/components/properties/edge/edgeroutesection.tsx","./src/components/properties/edge/edgestylesection.tsx","./src/components/properties/edge/sequencemessagesection.tsx","./src/components/properties/edge/architecturesemantics.test.ts","./src/components/properties/edge/architecturesemantics.ts","./src/components/properties/edge/edgecolorutils.ts","./src/components/properties/edge/edgelabelmodel.test.ts","./src/components/properties/edge/edgelabelmodel.ts","./src/components/properties/edge/errelationoptions.ts","./src/components/properties/families/architecturenodeproperties.test.tsx","./src/components/properties/families/architecturenodeproperties.tsx","./src/components/properties/families/architecturenodesection.tsx","./src/components/properties/families/classdiagramnodeproperties.tsx","./src/components/properties/families/classmemberlisteditor.tsx","./src/components/properties/families/classnodesection.tsx","./src/components/properties/families/erdiagramnodeproperties.tsx","./src/components/properties/families/entityfieldlisteditor.tsx","./src/components/properties/families/entitynodesection.tsx","./src/components/properties/families/journeynodeproperties.tsx","./src/components/properties/families/journeynodesection.tsx","./src/components/properties/families/mindmapnodeproperties.test.tsx","./src/components/properties/families/mindmapnodeproperties.tsx","./src/components/properties/families/sequencenodeproperties.tsx","./src/components/properties/families/sequencenodesection.tsx","./src/components/properties/families/specializednodecolorsections.test.tsx","./src/components/properties/families/structuredtextlisteditor.tsx","./src/components/properties/families/architectureoptions.ts","./src/components/studio-code-panel/usestudiocodepanelcontroller.test.tsx","./src/components/studio-code-panel/usestudiocodepanelcontroller.ts","./src/components/templates/templatepresentation.tsx","./src/components/toolbar/toolbaraddmenu.test.tsx","./src/components/toolbar/toolbaraddmenu.tsx","./src/components/toolbar/toolbaraddmenupanel.tsx","./src/components/toolbar/toolbarhistorycontrols.tsx","./src/components/toolbar/toolbarmodecontrols.tsx","./src/components/toolbar/toolbarbuttonstyles.ts","./src/components/top-nav/savestatusindicator.tsx","./src/components/top-nav/topnavactions.tsx","./src/components/top-nav/topnavbrand.tsx","./src/components/top-nav/topnavmenu.test.tsx","./src/components/top-nav/topnavmenu.tsx","./src/components/top-nav/topnavmenupanel.tsx","./src/components/top-nav/usetopnavstate.ts","./src/components/ui/button.tsx","./src/components/ui/collapsiblesection.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/searchfield.tsx","./src/components/ui/segmentedtabs.tsx","./src/components/ui/select.test.tsx","./src/components/ui/select.tsx","./src/components/ui/sidebaritem.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toastcontext.tsx","./src/components/ui/editorfieldstyles.ts","./src/config/aiproviders.test.ts","./src/config/aiproviders.ts","./src/config/rolloutflags.ts","./src/context/architecturelintcontext.tsx","./src/context/cinematicexportcontext.tsx","./src/context/diagramdiffcontext.tsx","./src/context/themecontext.tsx","./src/diagram-types/bootstrap.test.ts","./src/diagram-types/bootstrap.ts","./src/diagram-types/builtinplugins.ts","./src/diagram-types/builtinpropertypanels.ts","./src/diagram-types/registerbuiltinplugins.test.ts","./src/diagram-types/registerbuiltinplugins.ts","./src/diagram-types/registerbuiltinpropertypanels.test.ts","./src/diagram-types/registerbuiltinpropertypanels.ts","./src/diagram-types/architecture/fuzzcorpus.test.ts","./src/diagram-types/architecture/plugin.test.ts","./src/diagram-types/architecture/plugin.ts","./src/diagram-types/classdiagram/fuzzcorpus.test.ts","./src/diagram-types/classdiagram/plugin.test.ts","./src/diagram-types/classdiagram/plugin.ts","./src/diagram-types/core/contracts.ts","./src/diagram-types/core/index.ts","./src/diagram-types/core/propertypanels.test.ts","./src/diagram-types/core/propertypanels.ts","./src/diagram-types/core/registry.ts","./src/diagram-types/erdiagram/fuzzcorpus.test.ts","./src/diagram-types/erdiagram/plugin.test.ts","./src/diagram-types/erdiagram/plugin.ts","./src/diagram-types/flowchart/plugin.ts","./src/diagram-types/journey/fuzzcorpus.test.ts","./src/diagram-types/journey/plugin.test.ts","./src/diagram-types/journey/plugin.ts","./src/diagram-types/mindmap/fuzzcorpus.test.ts","./src/diagram-types/mindmap/plugin.test.ts","./src/diagram-types/mindmap/plugin.ts","./src/diagram-types/sequence/plugin.test.ts","./src/diagram-types/sequence/plugin.ts","./src/diagram-types/statediagram/plugin.test.ts","./src/diagram-types/statediagram/plugin.ts","./src/docs/docsroutes.ts","./src/docs/publicdocscatalog.js","./src/hooks/edgeconnectinteractions.test.ts","./src/hooks/edgeconnectinteractions.ts","./src/hooks/flowexportviewport.test.ts","./src/hooks/flowexportviewport.ts","./src/hooks/mindmaptopicactionrequest.ts","./src/hooks/nodelabeleditrequest.test.ts","./src/hooks/nodelabeleditrequest.ts","./src/hooks/nodequickcreaterequest.test.ts","./src/hooks/nodequickcreaterequest.ts","./src/hooks/snapshotpolicy.test.ts","./src/hooks/snapshotpolicy.ts","./src/hooks/useaigeneration.ts","./src/hooks/useanalyticspreference.ts","./src/hooks/useanimatededgeperformancewarning.test.ts","./src/hooks/useanimatededgeperformancewarning.ts","./src/hooks/useassetcatalog.ts","./src/hooks/usecinematicexport.ts","./src/hooks/useclipboardoperations.ts","./src/hooks/usedesignsystem.ts","./src/hooks/useedgeinteractions.ts","./src/hooks/useedgeoperations.ts","./src/hooks/usefloweditoractions.test.ts","./src/hooks/usefloweditoractions.ts","./src/hooks/usefloweditorcallbacks.ts","./src/hooks/usefloweditorcollaboration.test.ts","./src/hooks/usefloweditorcollaboration.ts","./src/hooks/usefloweditoruistate.ts","./src/hooks/useflowexport.ts","./src/hooks/useflowhistory.test.ts","./src/hooks/useflowhistory.ts","./src/hooks/useflowoperations.ts","./src/hooks/usegithubstars.ts","./src/hooks/useinfrasync.ts","./src/hooks/useinlinenodetextedit.test.ts","./src/hooks/useinlinenodetextedit.ts","./src/hooks/usekeyboardshortcuts.test.ts","./src/hooks/usekeyboardshortcuts.ts","./src/hooks/uselayoutoperations.ts","./src/hooks/usemarkdowneditor.ts","./src/hooks/usemenukeyboardnavigation.ts","./src/hooks/usemodifierkeys.test.ts","./src/hooks/usemodifierkeys.ts","./src/hooks/usenodeoperations.ts","./src/hooks/useplayback.ts","./src/hooks/useprovidershapepreview.ts","./src/hooks/userecentimports.ts","./src/hooks/useshiftheld.ts","./src/hooks/usesnapshots.ts","./src/hooks/usestaticexport.ts","./src/hooks/usestoragepressureguard.ts","./src/hooks/usestructuredlisteditor.ts","./src/hooks/usestyleclipboard.test.ts","./src/hooks/usestyleclipboard.ts","./src/hooks/ai-generation/chathistorystorage.test.ts","./src/hooks/ai-generation/chathistorystorage.ts","./src/hooks/ai-generation/codetoarchitecture.test.ts","./src/hooks/ai-generation/codetoarchitecture.ts","./src/hooks/ai-generation/codebaseanalyzer.test.ts","./src/hooks/ai-generation/codebaseanalyzer.ts","./src/hooks/ai-generation/codebaseparser.test.ts","./src/hooks/ai-generation/codebaseparser.ts","./src/hooks/ai-generation/codebasetonativediagram.test.ts","./src/hooks/ai-generation/codebasetonativediagram.ts","./src/hooks/ai-generation/graphcomposer.test.ts","./src/hooks/ai-generation/graphcomposer.ts","./src/hooks/ai-generation/nodeactionprompts.test.ts","./src/hooks/ai-generation/nodeactionprompts.ts","./src/hooks/ai-generation/openapiparser.test.ts","./src/hooks/ai-generation/openapiparser.ts","./src/hooks/ai-generation/openapitosequence.ts","./src/hooks/ai-generation/positionpreservingapply.test.ts","./src/hooks/ai-generation/positionpreservingapply.ts","./src/hooks/ai-generation/readiness.test.ts","./src/hooks/ai-generation/readiness.ts","./src/hooks/ai-generation/requestlifecycle.test.ts","./src/hooks/ai-generation/requestlifecycle.ts","./src/hooks/ai-generation/sqlparser.test.ts","./src/hooks/ai-generation/sqlparser.ts","./src/hooks/ai-generation/sqltoerd.ts","./src/hooks/ai-generation/streamingparser.ts","./src/hooks/ai-generation/streamingstore.ts","./src/hooks/ai-generation/terraformtocloud.ts","./src/hooks/edge-operations/utils.test.ts","./src/hooks/edge-operations/utils.ts","./src/hooks/flow-editor-actions/exporthandlers.test.ts","./src/hooks/flow-editor-actions/exporthandlers.ts","./src/hooks/flow-editor-actions/helpers.ts","./src/hooks/flow-editor-actions/layouthandlers.test.ts","./src/hooks/flow-editor-actions/layouthandlers.ts","./src/hooks/flow-export/diagramdocumenttransfer.test.ts","./src/hooks/flow-export/diagramdocumenttransfer.ts","./src/hooks/flow-export/exportcapture.test.ts","./src/hooks/flow-export/exportcapture.ts","./src/hooks/flow-operations/useflowcoreactions.ts","./src/hooks/node-operations/createconnectedsibling.ts","./src/hooks/node-operations/dragstopreconcilepolicy.test.ts","./src/hooks/node-operations/dragstopreconcilepolicy.ts","./src/hooks/node-operations/nodefactories.ts","./src/hooks/node-operations/routingduringdrag.test.ts","./src/hooks/node-operations/routingduringdrag.ts","./src/hooks/node-operations/sectionbounds.ts","./src/hooks/node-operations/sectionhittesting.ts","./src/hooks/node-operations/sectionoperations.ts","./src/hooks/node-operations/usearchitecturenodeoperations.test.ts","./src/hooks/node-operations/usearchitecturenodeoperations.ts","./src/hooks/node-operations/usemindmapnodeoperations.test.ts","./src/hooks/node-operations/usemindmapnodeoperations.ts","./src/hooks/node-operations/usenodedragoperations.test.ts","./src/hooks/node-operations/usenodedragoperations.ts","./src/hooks/node-operations/usenodeoperationadders.ts","./src/hooks/node-operations/utils.test.ts","./src/hooks/node-operations/utils.ts","./src/i18n/config.test.ts","./src/i18n/config.ts","./src/i18n/strictmodelocalecoverage.test.ts","./src/i18n/usedlocalecoverage.test.ts","./src/lib/architecturetemplatedata.ts","./src/lib/architecturetemplates.test.ts","./src/lib/architecturetemplates.ts","./src/lib/brand.ts","./src/lib/classmembers.ts","./src/lib/colorutils.ts","./src/lib/connectcreationpolicy.test.ts","./src/lib/connectcreationpolicy.ts","./src/lib/date.ts","./src/lib/designtokens.ts","./src/lib/devperformance.ts","./src/lib/entityfields.ts","./src/lib/ertoclassconversion.test.ts","./src/lib/ertoclassconversion.ts","./src/lib/exportfilename.test.ts","./src/lib/exportfilename.ts","./src/lib/flowminddslparserv2.test.ts","./src/lib/flowminddslparserv2.ts","./src/lib/fuzzymatch.ts","./src/lib/genericshapepolicy.ts","./src/lib/id.ts","./src/lib/index.ts","./src/lib/legacybranding.ts","./src/lib/logger.ts","./src/lib/mermaidparser.ts","./src/lib/mermaidparserhelpers.ts","./src/lib/mermaidparsermodel.ts","./src/lib/mindmaplayout.test.ts","./src/lib/mindmaplayout.ts","./src/lib/mindmaplayoutengine.ts","./src/lib/mindmaptree.ts","./src/lib/nodebulkediting.test.ts","./src/lib/nodebulkediting.ts","./src/lib/nodehandles.ts","./src/lib/nodeparent.ts","./src/lib/nodestyledata.test.ts","./src/lib/nodestyledata.ts","./src/lib/openflowdslparser.ts","./src/lib/openflowdslparserv2.ts","./src/lib/reactflowcompat.ts","./src/lib/reconnectedge.test.ts","./src/lib/reconnectedge.ts","./src/lib/relationsemantics.test.ts","./src/lib/relationsemantics.ts","./src/lib/releasestaleelkroutes.test.ts","./src/lib/releasestaleelkroutes.ts","./src/lib/result.ts","./src/lib/storagepressure.test.ts","./src/lib/storagepressure.ts","./src/lib/types.ts","./src/lib/xsssafety.test.tsx","./src/services/aligndistribute.ts","./src/services/aiservice.test.ts","./src/services/aiservice.ts","./src/services/aiserviceschemas.ts","./src/services/animatedexport.test.ts","./src/services/animatedexport.ts","./src/services/architectureroundtrip.test.ts","./src/services/assetcatalog.test.ts","./src/services/assetcatalog.ts","./src/services/assetpresentation.ts","./src/services/canonicalserialization.test.ts","./src/services/canonicalserialization.ts","./src/services/composediagramfordisplay.test.ts","./src/services/composediagramfordisplay.ts","./src/services/diagramdocument.test.ts","./src/services/diagramdocument.ts","./src/services/diagramdocumentschemas.ts","./src/services/domainlibrary.test.ts","./src/services/domainlibrary.ts","./src/services/elklayout.test.ts","./src/services/elklayout.ts","./src/services/exportservice.test.ts","./src/services/exportservice.ts","./src/services/figmaexportservice.ts","./src/services/flowchartroundtrip.test.ts","./src/services/geminiservice.ts","./src/services/geminisysteminstruction.ts","./src/services/gifencoder.test.ts","./src/services/gifencoder.ts","./src/services/githubfetcher.test.ts","./src/services/githubfetcher.ts","./src/services/iconassetcatalog.ts","./src/services/importfidelity.test.ts","./src/services/importfidelity.ts","./src/services/mermaidparser.test.ts","./src/services/openflowdslexporter.test.ts","./src/services/openflowdslexporter.ts","./src/services/openflowdslparser.test.ts","./src/services/openflowroundtripgolden.test.ts","./src/services/openflowroundtripgoldenfixtures.ts","./src/services/operationfeedback.ts","./src/services/remainingfamiliesroundtrip.test.ts","./src/services/smartedgerouting.test.ts","./src/services/smartedgerouting.ts","./src/services/statediagramroundtrip.test.ts","./src/services/templates.selector.test.ts","./src/services/templates.ts","./src/services/zipextractor.ts","./src/services/ai/contextserializer.test.ts","./src/services/ai/contextserializer.ts","./src/services/analytics/analytics.ts","./src/services/analytics/analyticssettings.test.ts","./src/services/analytics/analyticssettings.ts","./src/services/analytics/surfaceanalyticsclient.ts","./src/services/architecturelint/defaultrules.ts","./src/services/architecturelint/ruleengine.test.ts","./src/services/architecturelint/ruleengine.ts","./src/services/architecturelint/rulelibrary.ts","./src/services/architecturelint/types.ts","./src/services/architecturelint/workspacerules.ts","./src/services/collaboration/bootstrap.test.ts","./src/services/collaboration/bootstrap.ts","./src/services/collaboration/canvasdiff.test.ts","./src/services/collaboration/canvasdiff.ts","./src/services/collaboration/comments.test.ts","./src/services/collaboration/comments.ts","./src/services/collaboration/contracts.test.ts","./src/services/collaboration/contracts.ts","./src/services/collaboration/hookutils.ts","./src/services/collaboration/operationlog.test.ts","./src/services/collaboration/operationlog.ts","./src/services/collaboration/presenceviewmodel.test.ts","./src/services/collaboration/presenceviewmodel.ts","./src/services/collaboration/reducer.test.ts","./src/services/collaboration/reducer.ts","./src/services/collaboration/roomconfig.ts","./src/services/collaboration/roomlink.test.ts","./src/services/collaboration/roomlink.ts","./src/services/collaboration/runtimecontroller.test.ts","./src/services/collaboration/runtimecontroller.ts","./src/services/collaboration/runtimehookutils.test.ts","./src/services/collaboration/runtimehookutils.ts","./src/services/collaboration/schemas.ts","./src/services/collaboration/session.test.ts","./src/services/collaboration/session.ts","./src/services/collaboration/storebridge.test.ts","./src/services/collaboration/storebridge.ts","./src/services/collaboration/transport.test.ts","./src/services/collaboration/transport.ts","./src/services/collaboration/transportfactory.test.ts","./src/services/collaboration/transportfactory.ts","./src/services/collaboration/types.ts","./src/services/collaboration/versioning.test.ts","./src/services/collaboration/versioning.ts","./src/services/collaboration/yjspeertransport.test.ts","./src/services/collaboration/yjspeertransport.ts","./src/services/diagramdiff/diffengine.ts","./src/services/elk-layout/boundaryfanout.ts","./src/services/elk-layout/determinism.ts","./src/services/elk-layout/options.ts","./src/services/elk-layout/types.ts","./src/services/export/cinematicbuildplan.test.ts","./src/services/export/cinematicbuildplan.ts","./src/services/export/cinematicexport.ts","./src/services/export/cinematicexporttheme.test.ts","./src/services/export/cinematicexporttheme.ts","./src/services/export/cinematicrenderstate.test.ts","./src/services/export/cinematicrenderstate.ts","./src/services/export/formatting.ts","./src/services/export/mermaidbuilder.test.ts","./src/services/export/mermaidbuilder.ts","./src/services/export/pdfdocument.test.ts","./src/services/export/pdfdocument.ts","./src/services/export/plantumlbuilder.ts","./src/services/export/mermaid/architecturemermaid.ts","./src/services/export/mermaid/classdiagrammermaid.ts","./src/services/export/mermaid/erdiagrammermaid.ts","./src/services/export/mermaid/journeymermaid.ts","./src/services/export/mermaid/mindmapmermaid.ts","./src/services/export/mermaid/sequencemermaid.ts","./src/services/export/mermaid/statediagrammermaid.ts","./src/services/figma/edgehelpers.ts","./src/services/figma/iconhelpers.ts","./src/services/figma/nodelayers.ts","./src/services/figma/themehelpers.ts","./src/services/figmaimport/figmaapiclient.ts","./src/services/flowpilot/assetgrounding.test.ts","./src/services/flowpilot/assetgrounding.ts","./src/services/flowpilot/prompting.ts","./src/services/flowpilot/responsepolicy.test.ts","./src/services/flowpilot/responsepolicy.ts","./src/services/flowpilot/skills.ts","./src/services/flowpilot/thread.ts","./src/services/flowpilot/types.ts","./src/services/infrasync/dockercomposeparser.test.ts","./src/services/infrasync/dockercomposeparser.ts","./src/services/infrasync/infratodsl.test.ts","./src/services/infrasync/infratodsl.ts","./src/services/infrasync/kubernetesparser.test.ts","./src/services/infrasync/kubernetesparser.ts","./src/services/infrasync/terraformstateparser.test.ts","./src/services/infrasync/terraformstateparser.ts","./src/services/infrasync/types.ts","./src/services/mermaid/detectdiagramtype.test.ts","./src/services/mermaid/detectdiagramtype.ts","./src/services/mermaid/diagnosticformatting.test.ts","./src/services/mermaid/diagnosticformatting.ts","./src/services/mermaid/parsemermaidbytype.test.ts","./src/services/mermaid/parsemermaidbytype.ts","./src/services/mermaid/strictmodediagnosticspresentation.test.ts","./src/services/mermaid/strictmodediagnosticspresentation.ts","./src/services/mermaid/strictmodeguidance.test.ts","./src/services/mermaid/strictmodeguidance.ts","./src/services/mermaid/strictmodeuxregression.test.ts","./src/services/offline/registerappshellserviceworker.test.ts","./src/services/offline/registerappshellserviceworker.ts","./src/services/onboarding/config.ts","./src/services/onboarding/eventschemas.ts","./src/services/onboarding/events.test.ts","./src/services/onboarding/events.ts","./src/services/playback/contracts.test.ts","./src/services/playback/contracts.ts","./src/services/playback/model.test.ts","./src/services/playback/model.ts","./src/services/playback/studio.test.ts","./src/services/playback/studio.ts","./src/services/sequence/sequencemessage.test.ts","./src/services/sequence/sequencemessage.ts","./src/services/shapelibrary/bootstrap.test.ts","./src/services/shapelibrary/bootstrap.ts","./src/services/shapelibrary/ingestionpipeline.test.ts","./src/services/shapelibrary/ingestionpipeline.ts","./src/services/shapelibrary/manifestvalidation.test.ts","./src/services/shapelibrary/manifestvalidation.ts","./src/services/shapelibrary/providercatalog.test.ts","./src/services/shapelibrary/providercatalog.ts","./src/services/shapelibrary/registry.test.ts","./src/services/shapelibrary/registry.ts","./src/services/shapelibrary/starterpacks.ts","./src/services/shapelibrary/types.ts","./src/services/storage/fallbackstorage.ts","./src/services/storage/flowdocumentmodel.test.ts","./src/services/storage/flowdocumentmodel.ts","./src/services/storage/flowpersiststorage.test.ts","./src/services/storage/flowpersiststorage.ts","./src/services/storage/indexeddbhelpers.ts","./src/services/storage/indexeddbschema.test.ts","./src/services/storage/indexeddbschema.ts","./src/services/storage/indexeddbstatestorage.test.ts","./src/services/storage/indexeddbstatestorage.ts","./src/services/storage/localfirstrepository.ts","./src/services/storage/localfirstruntime.ts","./src/services/storage/persisteddocumentadapters.test.ts","./src/services/storage/persisteddocumentadapters.ts","./src/services/storage/persistencetypes.ts","./src/services/storage/snapshotstorage.test.ts","./src/services/storage/snapshotstorage.ts","./src/services/storage/storageruntime.test.ts","./src/services/storage/storageruntime.ts","./src/services/storage/storageschemas.test.ts","./src/services/storage/storageschemas.ts","./src/services/storage/storagetelemetry.test.ts","./src/services/storage/storagetelemetry.ts","./src/services/storage/storagetelemetrysink.test.ts","./src/services/storage/storagetelemetrysink.ts","./src/services/storage/uilocalstorage.test.ts","./src/services/storage/uilocalstorage.ts","./src/services/templatelibrary/registry.test.ts","./src/services/templatelibrary/registry.ts","./src/services/templatelibrary/startertemplates.assets.test.ts","./src/services/templatelibrary/startertemplates.test.ts","./src/services/templatelibrary/startertemplates.ts","./src/services/templatelibrary/templatefactories.ts","./src/services/templatelibrary/types.ts","./src/store/actionfactory.ts","./src/store/aisettings.test.ts","./src/store/aisettings.ts","./src/store/aisettingspersistence.ts","./src/store/aisettingsschemas.test.ts","./src/store/aisettingsschemas.ts","./src/store/canvashooks.ts","./src/store/createflowstore.test.ts","./src/store/createflowstore.ts","./src/store/createflowstorepersistoptions.ts","./src/store/createflowstorestate.ts","./src/store/defaults.ts","./src/store/designsystemhooks.ts","./src/store/documenthooks.ts","./src/store/documentstatesync.test.ts","./src/store/documentstatesync.ts","./src/store/editorpagehooks.ts","./src/store/historyhooks.ts","./src/store/historystate.ts","./src/store/persistence.test.ts","./src/store/persistence.ts","./src/store/persistenceschemas.ts","./src/store/selectionhooks.ts","./src/store/selectors.test.ts","./src/store/selectors.ts","./src/store/tabhooks.ts","./src/store/types.ts","./src/store/viewhooks.ts","./src/store/workspacedocumentmodel.test.ts","./src/store/workspacedocumentmodel.ts","./src/store/actions/createaiandselectionactions.ts","./src/store/actions/createcanvasactions.ts","./src/store/actions/createdesignsystemactions.ts","./src/store/actions/createhistoryactions.test.ts","./src/store/actions/createhistoryactions.ts","./src/store/actions/createlayeractions.ts","./src/store/actions/createtabactions.ts","./src/store/actions/createviewactions.ts","./src/store/actions/createworkspacedocumentactions.ts","./src/store/actions/synctabnodesedges.ts","./src/store/slices/createcanvaseditorslice.ts","./src/store/slices/createexperienceslice.ts","./src/store/slices/createworkspaceslice.ts"],"version":"5.8.3"}
\ No newline at end of file
+{"root":["./src/app.test.tsx","./src/app.tsx","./src/constants.test.ts","./src/constants.ts","./src/index.tsx","./src/store.test.ts","./src/store.ts","./src/theme.test.ts","./src/theme.ts","./src/app/routestate.test.ts","./src/app/routestate.ts","./src/components/annotationnode.tsx","./src/components/architecturerulespanel.tsx","./src/components/cinematicexportoverlay.tsx","./src/components/commandbar.test.tsx","./src/components/commandbar.tsx","./src/components/connectmenu.test.tsx","./src/components/connectmenu.tsx","./src/components/connectmenusections.tsx","./src/components/contextmenu.test.tsx","./src/components/contextmenu.tsx","./src/components/customconnectionline.test.tsx","./src/components/customconnectionline.tsx","./src/components/customedge.tsx","./src/components/customnode.handleinteraction.test.tsx","./src/components/customnode.tsx","./src/components/customnodecontent.tsx","./src/components/designsystem.integration.test.tsx","./src/components/diagramviewer.tsx","./src/components/errorboundary.tsx","./src/components/exportmenu.tsx","./src/components/exportmenupanel.test.tsx","./src/components/exportmenupanel.tsx","./src/components/flowcanvas.tsx","./src/components/floweditor.test.tsx","./src/components/floweditor.tsx","./src/components/floweditoremptystate.tsx","./src/components/floweditorlayoutoverlay.tsx","./src/components/floweditorpanels.test.tsx","./src/components/floweditorpanels.tsx","./src/components/flowtabs.test.tsx","./src/components/flowtabs.tsx","./src/components/groupnode.tsx","./src/components/homepage.integration.test.tsx","./src/components/homepage.tsx","./src/components/iconassetnodebody.tsx","./src/components/iconmap.test.tsx","./src/components/iconmap.ts","./src/components/imagenode.tsx","./src/components/importrecoverydialog.test.tsx","./src/components/importrecoverydialog.tsx","./src/components/inlinetexteditsurface.test.tsx","./src/components/inlinetexteditsurface.tsx","./src/components/keyboardshortcutsmodal.tsx","./src/components/languageselector.tsx","./src/components/markdownrenderer.tsx","./src/components/memoizedmarkdown.tsx","./src/components/mermaiddiagnosticsbanner.test.tsx","./src/components/mermaiddiagnosticsbanner.tsx","./src/components/navigationcontrols.tsx","./src/components/nodebadges.tsx","./src/components/nodechrome.tsx","./src/components/nodequickcreatebuttons.tsx","./src/components/nodeshapesvg.tsx","./src/components/nodetransformcontrols.tsx","./src/components/playbackcontrols.tsx","./src/components/propertiespanel.tsx","./src/components/rightrail.tsx","./src/components/sectionnode.tsx","./src/components/shareembedmodal.tsx","./src/components/sharemodal.test.tsx","./src/components/sharemodal.tsx","./src/components/sidebarshell.tsx","./src/components/snapshotspanel.test.tsx","./src/components/snapshotspanel.tsx","./src/components/studioaipanel.test.tsx","./src/components/studioaipanel.tsx","./src/components/studioaipanelsections.tsx","./src/components/studiocodepanel.test.tsx","./src/components/studiocodepanel.tsx","./src/components/studiopanel.test.tsx","./src/components/studiopanel.tsx","./src/components/studioplaybackpanel.test.tsx","./src/components/studioplaybackpanel.tsx","./src/components/swimlanenode.tsx","./src/components/textnode.tsx","./src/components/themetoggle.tsx","./src/components/toolbar.tsx","./src/components/tooltip.tsx","./src/components/topnav.tsx","./src/components/welcomemodal.tsx","./src/components/annotationtheme.ts","./src/components/container-nodes.handleinteraction.test.tsx","./src/components/editorsurfacetiers.ts","./src/components/handleinteraction.test.ts","./src/components/handleinteraction.ts","./src/components/handleinteractionusage.test.ts","./src/components/lightweight-nodes.handleinteraction.test.tsx","./src/components/markdownsyntax.ts","./src/components/nodehelpers.test.ts","./src/components/nodehelpers.ts","./src/components/sharemodalcontent.ts","./src/components/studioaicopy.ts","./src/components/studioaipanelexamples.ts","./src/components/transformdiagnostics.test.ts","./src/components/transformdiagnostics.ts","./src/components/useactivenodeselection.ts","./src/components/useexportmenu.test.tsx","./src/components/useexportmenu.ts","./src/components/settingsmodal/aisettings.tsx","./src/components/settingsmodal/canvassettings.tsx","./src/components/settingsmodal/generalsettings.test.tsx","./src/components/settingsmodal/generalsettings.tsx","./src/components/settingsmodal/settingsmodal.test.tsx","./src/components/settingsmodal/settingsmodal.tsx","./src/components/settingsmodal/shortcutssettings.tsx","./src/components/settingsmodal/ai/advancedendpointsection.tsx","./src/components/settingsmodal/ai/customheaderseditor.tsx","./src/components/settingsmodal/ai/privacysection.tsx","./src/components/settingsmodal/ai/providericon.tsx","./src/components/settingsmodal/ai/providersection.tsx","./src/components/settingsmodal/ai/step.tsx","./src/components/add-items/additemregistry.tsx","./src/components/app/docssiteredirect.test.tsx","./src/components/app/docssiteredirect.tsx","./src/components/app/mobileworkspacegate.test.tsx","./src/components/app/mobileworkspacegate.tsx","./src/components/app/routeloadingfallback.test.tsx","./src/components/app/routeloadingfallback.tsx","./src/components/app/mobileworkspacegatecopy.ts","./src/components/app/routeloadingcopy.ts","./src/components/architecture-lint/lintrulespanel.tsx","./src/components/architecture-lint/ruleform.tsx","./src/components/architecture-lint/visualeditor.tsx","./src/components/command-bar/assetsview.test.tsx","./src/components/command-bar/assetsview.tsx","./src/components/command-bar/codebaseimportpanels.tsx","./src/components/command-bar/codebaseimportsection.tsx","./src/components/command-bar/designsystemeditor.tsx","./src/components/command-bar/designsystemview.tsx","./src/components/command-bar/diagramminipreview.tsx","./src/components/command-bar/figmaimportpanel.tsx","./src/components/command-bar/importsurfaceprimitives.tsx","./src/components/command-bar/importview.tsx","./src/components/command-bar/importviewpanels.tsx","./src/components/command-bar/layersview.tsx","./src/components/command-bar/layoutview.tsx","./src/components/command-bar/pagesview.tsx","./src/components/command-bar/rootview.test.tsx","./src/components/command-bar/rootview.tsx","./src/components/command-bar/searchview.tsx","./src/components/command-bar/templatesview.test.tsx","./src/components/command-bar/templatesview.tsx","./src/components/command-bar/viewheader.tsx","./src/components/command-bar/visualsview.tsx","./src/components/command-bar/applycodechanges.test.ts","./src/components/command-bar/applycodechanges.ts","./src/components/command-bar/assetsviewconstants.ts","./src/components/command-bar/importdetection.test.ts","./src/components/command-bar/importdetection.ts","./src/components/command-bar/importnativeparsers.ts","./src/components/command-bar/importviewmodel.test.ts","./src/components/command-bar/importviewmodel.ts","./src/components/command-bar/mermaidimportparser.ts","./src/components/command-bar/searchquery.test.ts","./src/components/command-bar/searchquery.ts","./src/components/command-bar/types.ts","./src/components/command-bar/useaiviewstate.test.tsx","./src/components/command-bar/useaiviewstate.ts","./src/components/command-bar/usecloudassetcatalog.ts","./src/components/command-bar/usecommandbarcommands.test.tsx","./src/components/command-bar/usecommandbarcommands.tsx","./src/components/custom-edge/customedgewrapper.tsx","./src/components/custom-edge/edgemarkerdefs.tsx","./src/components/custom-edge/sequencemessageedge.test.tsx","./src/components/custom-edge/sequencemessageedge.tsx","./src/components/custom-edge/animatededgepresentation.test.ts","./src/components/custom-edge/animatededgepresentation.ts","./src/components/custom-edge/classrelationsemantics.test.ts","./src/components/custom-edge/classrelationsemantics.ts","./src/components/custom-edge/edgerendermode.ts","./src/components/custom-edge/pathutils.test.ts","./src/components/custom-edge/pathutils.ts","./src/components/custom-edge/pathutilsgeometry.ts","./src/components/custom-edge/pathutilssiblingrouting.ts","./src/components/custom-edge/pathutilstypes.ts","./src/components/custom-edge/relationroutingsemantics.test.ts","./src/components/custom-edge/relationroutingsemantics.ts","./src/components/custom-edge/standardedgemarkers.test.ts","./src/components/custom-edge/standardedgemarkers.ts","./src/components/custom-nodes/architecturenode.handleinteraction.test.tsx","./src/components/custom-nodes/architecturenode.tsx","./src/components/custom-nodes/browsernode.tsx","./src/components/custom-nodes/classentitynode.handleinteraction.test.tsx","./src/components/custom-nodes/classnode.tsx","./src/components/custom-nodes/entitynode.tsx","./src/components/custom-nodes/journeynode.tsx","./src/components/custom-nodes/mindmapnode.tsx","./src/components/custom-nodes/mobilenode.tsx","./src/components/custom-nodes/sequencenotenode.tsx","./src/components/custom-nodes/sequenceparticipantnode.tsx","./src/components/custom-nodes/structurednodehandles.tsx","./src/components/custom-nodes/visualnodes.handleinteraction.test.tsx","./src/components/custom-nodes/browservariantrenderer.tsx","./src/components/custom-nodes/mobilevariantrenderer.tsx","./src/components/custom-nodes/structuredlistnavigation.test.ts","./src/components/custom-nodes/structuredlistnavigation.ts","./src/components/custom-nodes/variantconstants.ts","./src/components/diagram-diff/diffmodebanner.tsx","./src/components/flow-canvas/flowcanvasalignmentguidesoverlay.tsx","./src/components/flow-canvas/flowcanvasoverlays.tsx","./src/components/flow-canvas/flowcanvassurface.test.tsx","./src/components/flow-canvas/flowcanvassurface.tsx","./src/components/flow-canvas/streamingoverlay.tsx","./src/components/flow-canvas/alignmentguides.test.ts","./src/components/flow-canvas/alignmentguides.ts","./src/components/flow-canvas/flowcanvasreactflowcontracts.ts","./src/components/flow-canvas/flowcanvastypes.test.ts","./src/components/flow-canvas/flowcanvastypes.tsx","./src/components/flow-canvas/largegraphsafetymode.test.ts","./src/components/flow-canvas/largegraphsafetymode.ts","./src/components/flow-canvas/nodechromecoverage.test.ts","./src/components/flow-canvas/pastehelpers.test.ts","./src/components/flow-canvas/pastehelpers.ts","./src/components/flow-canvas/useflowcanvasalignmentguides.ts","./src/components/flow-canvas/useflowcanvasconnectionstate.ts","./src/components/flow-canvas/useflowcanvascontextactions.ts","./src/components/flow-canvas/useflowcanvasdragdrop.ts","./src/components/flow-canvas/useflowcanvasinteractionlod.ts","./src/components/flow-canvas/useflowcanvasmenus.ts","./src/components/flow-canvas/useflowcanvasmenusandactions.test.tsx","./src/components/flow-canvas/useflowcanvasmenusandactions.ts","./src/components/flow-canvas/useflowcanvasnodedraghandlers.ts","./src/components/flow-canvas/useflowcanvaspaste.ts","./src/components/flow-canvas/useflowcanvasreactflowconfig.test.ts","./src/components/flow-canvas/useflowcanvasreactflowconfig.ts","./src/components/flow-canvas/useflowcanvasselectiontools.test.tsx","./src/components/flow-canvas/useflowcanvasselectiontools.ts","./src/components/flow-canvas/useflowcanvasviewstate.test.ts","./src/components/flow-canvas/useflowcanvasviewstate.ts","./src/components/flow-canvas/useflowcanvaszoomlod.ts","./src/components/flow-editor/collaborationpresenceoverlay.test.tsx","./src/components/flow-editor/collaborationpresenceoverlay.tsx","./src/components/flow-editor/floweditorchrome.tsx","./src/components/flow-editor/buildfloweditorcontrollerparams.ts","./src/components/flow-editor/buildfloweditorscreencontrollerparams.ts","./src/components/flow-editor/chromeproptypes.ts","./src/components/flow-editor/floweditorchromeprops.ts","./src/components/flow-editor/infradslapply.test.ts","./src/components/flow-editor/infradslapply.ts","./src/components/flow-editor/panelprops.test.ts","./src/components/flow-editor/panelprops.ts","./src/components/flow-editor/shouldexitstudioonselection.test.ts","./src/components/flow-editor/shouldexitstudioonselection.ts","./src/components/flow-editor/usecollaborationnodepositions.ts","./src/components/flow-editor/usefloweditorcontroller.ts","./src/components/flow-editor/usefloweditorinteractionbindings.ts","./src/components/flow-editor/usefloweditorpanelactions.ts","./src/components/flow-editor/usefloweditorpanelprops.ts","./src/components/flow-editor/usefloweditorruntime.ts","./src/components/flow-editor/usefloweditorscreenbehavior.ts","./src/components/flow-editor/usefloweditorscreenmodel.ts","./src/components/flow-editor/usefloweditorscreenstate.ts","./src/components/flow-editor/usefloweditorshellcontroller.test.tsx","./src/components/flow-editor/usefloweditorshellcontroller.ts","./src/components/flow-editor/usefloweditorstudiocontroller.test.tsx","./src/components/flow-editor/usefloweditorstudiocontroller.ts","./src/components/flow-editor/useinfradslapply.ts","./src/components/home/githubcard.tsx","./src/components/home/homedashboard.tsx","./src/components/home/homeflowdialogs.tsx","./src/components/home/homesettingsview.tsx","./src/components/home/homesidebar.tsx","./src/components/home/hometemplatesview.tsx","./src/components/home/sidebarfooter.tsx","./src/components/home/welcomemodalstate.ts","./src/components/icons/assetsicon.tsx","./src/components/icons/openflowlogo.tsx","./src/components/infra-sync/infrasyncpanel.test.tsx","./src/components/infra-sync/infrasyncpanel.tsx","./src/components/journey/journeyscorecontrol.test.tsx","./src/components/journey/journeyscorecontrol.tsx","./src/components/properties/bulknodeproperties.test.tsx","./src/components/properties/bulknodeproperties.tsx","./src/components/properties/bulknodepropertiesfamilysections.tsx","./src/components/properties/bulknodepropertiessections.tsx","./src/components/properties/bulknodepropertiesutilitysections.tsx","./src/components/properties/colorpicker.test.tsx","./src/components/properties/colorpicker.tsx","./src/components/properties/customcolorpopover.tsx","./src/components/properties/diagramnodepropertiesrouter.test.ts","./src/components/properties/diagramnodepropertiesrouter.tsx","./src/components/properties/edgeproperties.tsx","./src/components/properties/iconpicker.tsx","./src/components/properties/icontilepickerprimitives.tsx","./src/components/properties/imageupload.tsx","./src/components/properties/inspectorprimitives.tsx","./src/components/properties/nodeactionbuttons.tsx","./src/components/properties/nodecontentsection.tsx","./src/components/properties/nodeimagesettingssection.tsx","./src/components/properties/nodeproperties.test.tsx","./src/components/properties/nodeproperties.tsx","./src/components/properties/nodewireframevariantsection.tsx","./src/components/properties/propertysliderrow.tsx","./src/components/properties/segmentedchoice.tsx","./src/components/properties/shapeselector.tsx","./src/components/properties/swatchpicker.tsx","./src/components/properties/bulknodepropertiesmodel.test.ts","./src/components/properties/bulknodepropertiesmodel.ts","./src/components/properties/colorpickerutils.ts","./src/components/properties/propertyinputbehavior.test.ts","./src/components/properties/propertyinputbehavior.ts","./src/components/properties/sectionactionbuilder.ts","./src/components/properties/shared.ts","./src/components/properties/wireframevariants.ts","./src/components/properties/edge/architectureedgesemanticssection.tsx","./src/components/properties/edge/edgecolorsection.test.tsx","./src/components/properties/edge/edgecolorsection.tsx","./src/components/properties/edge/edgeconditionsection.tsx","./src/components/properties/edge/edgelabelsection.tsx","./src/components/properties/edge/edgerelationsection.test.tsx","./src/components/properties/edge/edgerelationsection.tsx","./src/components/properties/edge/edgeroutesection.test.tsx","./src/components/properties/edge/edgeroutesection.tsx","./src/components/properties/edge/edgestylesection.tsx","./src/components/properties/edge/sequencemessagesection.tsx","./src/components/properties/edge/architecturesemantics.test.ts","./src/components/properties/edge/architecturesemantics.ts","./src/components/properties/edge/edgecolorutils.ts","./src/components/properties/edge/edgelabelmodel.test.ts","./src/components/properties/edge/edgelabelmodel.ts","./src/components/properties/edge/errelationoptions.ts","./src/components/properties/families/architecturenodeproperties.test.tsx","./src/components/properties/families/architecturenodeproperties.tsx","./src/components/properties/families/architecturenodesection.tsx","./src/components/properties/families/classdiagramnodeproperties.tsx","./src/components/properties/families/classmemberlisteditor.tsx","./src/components/properties/families/classnodesection.tsx","./src/components/properties/families/erdiagramnodeproperties.tsx","./src/components/properties/families/entityfieldlisteditor.tsx","./src/components/properties/families/entitynodesection.tsx","./src/components/properties/families/journeynodeproperties.tsx","./src/components/properties/families/journeynodesection.tsx","./src/components/properties/families/mindmapnodeproperties.test.tsx","./src/components/properties/families/mindmapnodeproperties.tsx","./src/components/properties/families/sequencenodeproperties.tsx","./src/components/properties/families/sequencenodesection.tsx","./src/components/properties/families/specializednodecolorsections.test.tsx","./src/components/properties/families/structuredtextlisteditor.tsx","./src/components/properties/families/architectureoptions.ts","./src/components/studio-code-panel/usestudiocodepanelcontroller.test.tsx","./src/components/studio-code-panel/usestudiocodepanelcontroller.ts","./src/components/templates/templatepresentation.tsx","./src/components/toolbar/toolbaraddmenu.test.tsx","./src/components/toolbar/toolbaraddmenu.tsx","./src/components/toolbar/toolbaraddmenupanel.tsx","./src/components/toolbar/toolbarhistorycontrols.tsx","./src/components/toolbar/toolbarmodecontrols.tsx","./src/components/toolbar/toolbarbuttonstyles.ts","./src/components/top-nav/savestatusindicator.tsx","./src/components/top-nav/topnavactions.tsx","./src/components/top-nav/topnavbrand.tsx","./src/components/top-nav/topnavmenu.test.tsx","./src/components/top-nav/topnavmenu.tsx","./src/components/top-nav/topnavmenupanel.tsx","./src/components/top-nav/usetopnavstate.ts","./src/components/ui/button.tsx","./src/components/ui/collapsiblesection.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/searchfield.tsx","./src/components/ui/segmentedtabs.tsx","./src/components/ui/select.test.tsx","./src/components/ui/select.tsx","./src/components/ui/sidebaritem.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toastcontext.tsx","./src/components/ui/editorfieldstyles.ts","./src/config/aiproviders.test.ts","./src/config/aiproviders.ts","./src/config/rolloutflags.ts","./src/context/architecturelintcontext.tsx","./src/context/cinematicexportcontext.tsx","./src/context/diagramdiffcontext.tsx","./src/context/themecontext.tsx","./src/diagram-types/bootstrap.test.ts","./src/diagram-types/bootstrap.ts","./src/diagram-types/builtinplugins.ts","./src/diagram-types/builtinpropertypanels.ts","./src/diagram-types/registerbuiltinplugins.test.ts","./src/diagram-types/registerbuiltinplugins.ts","./src/diagram-types/registerbuiltinpropertypanels.test.ts","./src/diagram-types/registerbuiltinpropertypanels.ts","./src/diagram-types/architecture/fuzzcorpus.test.ts","./src/diagram-types/architecture/plugin.test.ts","./src/diagram-types/architecture/plugin.ts","./src/diagram-types/classdiagram/fuzzcorpus.test.ts","./src/diagram-types/classdiagram/plugin.test.ts","./src/diagram-types/classdiagram/plugin.ts","./src/diagram-types/core/contracts.ts","./src/diagram-types/core/index.ts","./src/diagram-types/core/propertypanels.test.ts","./src/diagram-types/core/propertypanels.ts","./src/diagram-types/core/registry.ts","./src/diagram-types/erdiagram/fuzzcorpus.test.ts","./src/diagram-types/erdiagram/plugin.test.ts","./src/diagram-types/erdiagram/plugin.ts","./src/diagram-types/flowchart/plugin.ts","./src/diagram-types/journey/fuzzcorpus.test.ts","./src/diagram-types/journey/plugin.test.ts","./src/diagram-types/journey/plugin.ts","./src/diagram-types/mindmap/fuzzcorpus.test.ts","./src/diagram-types/mindmap/plugin.test.ts","./src/diagram-types/mindmap/plugin.ts","./src/diagram-types/sequence/plugin.test.ts","./src/diagram-types/sequence/plugin.ts","./src/diagram-types/statediagram/plugin.test.ts","./src/diagram-types/statediagram/plugin.ts","./src/docs/docsroutes.ts","./src/docs/publicdocscatalog.js","./src/hooks/edgeconnectinteractions.test.ts","./src/hooks/edgeconnectinteractions.ts","./src/hooks/flowexportviewport.test.ts","./src/hooks/flowexportviewport.ts","./src/hooks/mindmaptopicactionrequest.ts","./src/hooks/nodelabeleditrequest.test.ts","./src/hooks/nodelabeleditrequest.ts","./src/hooks/nodequickcreaterequest.test.ts","./src/hooks/nodequickcreaterequest.ts","./src/hooks/snapshotpolicy.test.ts","./src/hooks/snapshotpolicy.ts","./src/hooks/useaigeneration.ts","./src/hooks/useanalyticspreference.ts","./src/hooks/useanimatededgeperformancewarning.test.ts","./src/hooks/useanimatededgeperformancewarning.ts","./src/hooks/useassetcatalog.ts","./src/hooks/usecinematicexport.ts","./src/hooks/useclipboardoperations.ts","./src/hooks/usedesignsystem.ts","./src/hooks/useedgeinteractions.ts","./src/hooks/useedgeoperations.ts","./src/hooks/usefloweditoractions.test.ts","./src/hooks/usefloweditoractions.ts","./src/hooks/usefloweditorcallbacks.ts","./src/hooks/usefloweditorcollaboration.test.ts","./src/hooks/usefloweditorcollaboration.ts","./src/hooks/usefloweditoruistate.ts","./src/hooks/useflowexport.ts","./src/hooks/useflowhistory.test.ts","./src/hooks/useflowhistory.ts","./src/hooks/useflowoperations.ts","./src/hooks/usegithubstars.ts","./src/hooks/useinfrasync.ts","./src/hooks/useinlinenodetextedit.test.ts","./src/hooks/useinlinenodetextedit.ts","./src/hooks/usekeyboardshortcuts.test.ts","./src/hooks/usekeyboardshortcuts.ts","./src/hooks/uselayoutoperations.ts","./src/hooks/usemarkdowneditor.ts","./src/hooks/usemenukeyboardnavigation.ts","./src/hooks/usemodifierkeys.test.ts","./src/hooks/usemodifierkeys.ts","./src/hooks/usenodeoperations.ts","./src/hooks/useplayback.ts","./src/hooks/useprovidershapepreview.ts","./src/hooks/userecentimports.ts","./src/hooks/useshiftheld.ts","./src/hooks/usesnapshots.ts","./src/hooks/usestaticexport.ts","./src/hooks/usestoragepressureguard.ts","./src/hooks/usestructuredlisteditor.ts","./src/hooks/usestyleclipboard.test.ts","./src/hooks/usestyleclipboard.ts","./src/hooks/ai-generation/chathistorystorage.test.ts","./src/hooks/ai-generation/chathistorystorage.ts","./src/hooks/ai-generation/codetoarchitecture.test.ts","./src/hooks/ai-generation/codetoarchitecture.ts","./src/hooks/ai-generation/codebaseanalyzer.test.ts","./src/hooks/ai-generation/codebaseanalyzer.ts","./src/hooks/ai-generation/codebaseparser.test.ts","./src/hooks/ai-generation/codebaseparser.ts","./src/hooks/ai-generation/codebasetonativediagram.test.ts","./src/hooks/ai-generation/codebasetonativediagram.ts","./src/hooks/ai-generation/graphcomposer.test.ts","./src/hooks/ai-generation/graphcomposer.ts","./src/hooks/ai-generation/nodeactionprompts.test.ts","./src/hooks/ai-generation/nodeactionprompts.ts","./src/hooks/ai-generation/openapiparser.test.ts","./src/hooks/ai-generation/openapiparser.ts","./src/hooks/ai-generation/openapitosequence.ts","./src/hooks/ai-generation/positionpreservingapply.test.ts","./src/hooks/ai-generation/positionpreservingapply.ts","./src/hooks/ai-generation/readiness.test.ts","./src/hooks/ai-generation/readiness.ts","./src/hooks/ai-generation/requestlifecycle.test.ts","./src/hooks/ai-generation/requestlifecycle.ts","./src/hooks/ai-generation/sqlparser.test.ts","./src/hooks/ai-generation/sqlparser.ts","./src/hooks/ai-generation/sqltoerd.ts","./src/hooks/ai-generation/streamingparser.ts","./src/hooks/ai-generation/streamingstore.ts","./src/hooks/ai-generation/terraformtocloud.ts","./src/hooks/edge-operations/utils.test.ts","./src/hooks/edge-operations/utils.ts","./src/hooks/flow-editor-actions/exporthandlers.test.ts","./src/hooks/flow-editor-actions/exporthandlers.ts","./src/hooks/flow-editor-actions/helpers.ts","./src/hooks/flow-editor-actions/layouthandlers.test.ts","./src/hooks/flow-editor-actions/layouthandlers.ts","./src/hooks/flow-export/diagramdocumenttransfer.test.ts","./src/hooks/flow-export/diagramdocumenttransfer.ts","./src/hooks/flow-export/exportcapture.test.ts","./src/hooks/flow-export/exportcapture.ts","./src/hooks/flow-operations/useflowcoreactions.ts","./src/hooks/node-operations/createconnectedsibling.ts","./src/hooks/node-operations/dragstopreconcilepolicy.test.ts","./src/hooks/node-operations/dragstopreconcilepolicy.ts","./src/hooks/node-operations/nodefactories.ts","./src/hooks/node-operations/routingduringdrag.test.ts","./src/hooks/node-operations/routingduringdrag.ts","./src/hooks/node-operations/sectionbounds.ts","./src/hooks/node-operations/sectionhittesting.ts","./src/hooks/node-operations/sectionoperations.ts","./src/hooks/node-operations/usearchitecturenodeoperations.test.ts","./src/hooks/node-operations/usearchitecturenodeoperations.ts","./src/hooks/node-operations/usemindmapnodeoperations.test.ts","./src/hooks/node-operations/usemindmapnodeoperations.ts","./src/hooks/node-operations/usenodedragoperations.test.ts","./src/hooks/node-operations/usenodedragoperations.ts","./src/hooks/node-operations/usenodeoperationadders.ts","./src/hooks/node-operations/utils.test.ts","./src/hooks/node-operations/utils.ts","./src/i18n/config.test.ts","./src/i18n/config.ts","./src/i18n/strictmodelocalecoverage.test.ts","./src/i18n/usedlocalecoverage.test.ts","./src/lib/aiiconspipeline.test.ts","./src/lib/architecturetemplatedata.ts","./src/lib/architecturetemplates.test.ts","./src/lib/architecturetemplates.ts","./src/lib/brand.ts","./src/lib/classmembers.ts","./src/lib/colorutils.ts","./src/lib/connectcreationpolicy.test.ts","./src/lib/connectcreationpolicy.ts","./src/lib/date.ts","./src/lib/designtokens.ts","./src/lib/devperformance.ts","./src/lib/entityfields.test.ts","./src/lib/entityfields.ts","./src/lib/ertoclassconversion.test.ts","./src/lib/ertoclassconversion.ts","./src/lib/exportfilename.test.ts","./src/lib/exportfilename.ts","./src/lib/flowminddslparserv2.test.ts","./src/lib/flowminddslparserv2.ts","./src/lib/fuzzymatch.ts","./src/lib/genericshapepolicy.ts","./src/lib/iconmatcher.test.ts","./src/lib/iconmatcher.ts","./src/lib/iconresolver.test.ts","./src/lib/iconresolver.ts","./src/lib/id.ts","./src/lib/index.ts","./src/lib/legacybranding.ts","./src/lib/logger.ts","./src/lib/mermaidenrichmentpipeline.test.ts","./src/lib/mermaidparser.ts","./src/lib/mermaidparserhelpers.ts","./src/lib/mermaidparsermodel.ts","./src/lib/mindmaplayout.test.ts","./src/lib/mindmaplayout.ts","./src/lib/mindmaplayoutengine.ts","./src/lib/mindmaptree.ts","./src/lib/nodebulkediting.test.ts","./src/lib/nodebulkediting.ts","./src/lib/nodeenricher.test.ts","./src/lib/nodeenricher.ts","./src/lib/nodehandles.ts","./src/lib/nodeiconstate.test.ts","./src/lib/nodeiconstate.ts","./src/lib/nodeparent.ts","./src/lib/nodestyledata.test.ts","./src/lib/nodestyledata.ts","./src/lib/openflowdslparser.ts","./src/lib/openflowdslparserv2.ts","./src/lib/reactflowcompat.ts","./src/lib/reconnectedge.test.ts","./src/lib/reconnectedge.ts","./src/lib/relationsemantics.test.ts","./src/lib/relationsemantics.ts","./src/lib/releasestaleelkroutes.test.ts","./src/lib/releasestaleelkroutes.ts","./src/lib/result.ts","./src/lib/semanticclassifier.test.ts","./src/lib/semanticclassifier.ts","./src/lib/storagepressure.test.ts","./src/lib/storagepressure.ts","./src/lib/types.ts","./src/lib/xsssafety.test.tsx","./src/services/aligndistribute.ts","./src/services/aiservice.test.ts","./src/services/aiservice.ts","./src/services/aiserviceschemas.ts","./src/services/animatedexport.test.ts","./src/services/animatedexport.ts","./src/services/architectureroundtrip.test.ts","./src/services/assetcatalog.test.ts","./src/services/assetcatalog.ts","./src/services/assetpresentation.ts","./src/services/canonicalserialization.test.ts","./src/services/canonicalserialization.ts","./src/services/composediagramfordisplay.test.ts","./src/services/composediagramfordisplay.ts","./src/services/diagramdocument.test.ts","./src/services/diagramdocument.ts","./src/services/diagramdocumentschemas.ts","./src/services/domainlibrary.test.ts","./src/services/domainlibrary.ts","./src/services/elklayout.test.ts","./src/services/elklayout.ts","./src/services/exportservice.test.ts","./src/services/exportservice.ts","./src/services/figmaexportservice.ts","./src/services/flowchartroundtrip.test.ts","./src/services/geminiservice.ts","./src/services/geminisysteminstruction.ts","./src/services/gifencoder.test.ts","./src/services/gifencoder.ts","./src/services/githubfetcher.test.ts","./src/services/githubfetcher.ts","./src/services/iconassetcatalog.ts","./src/services/importfidelity.test.ts","./src/services/importfidelity.ts","./src/services/importlayoutmetadata.ts","./src/services/mermaidparser.test.ts","./src/services/openflowdslexporter.test.ts","./src/services/openflowdslexporter.ts","./src/services/openflowdslparser.test.ts","./src/services/openflowroundtripgolden.test.ts","./src/services/openflowroundtripgoldenfixtures.ts","./src/services/operationfeedback.ts","./src/services/remainingfamiliesroundtrip.test.ts","./src/services/sequencelayout.test.ts","./src/services/sequencelayout.ts","./src/services/smartedgerouting.test.ts","./src/services/smartedgerouting.ts","./src/services/statediagramroundtrip.test.ts","./src/services/templates.selector.test.ts","./src/services/templates.ts","./src/services/zipextractor.ts","./src/services/ai/contextserializer.test.ts","./src/services/ai/contextserializer.ts","./src/services/analytics/analytics.ts","./src/services/analytics/analyticssettings.test.ts","./src/services/analytics/analyticssettings.ts","./src/services/analytics/surfaceanalyticsclient.ts","./src/services/architecturelint/defaultrules.ts","./src/services/architecturelint/ruleengine.test.ts","./src/services/architecturelint/ruleengine.ts","./src/services/architecturelint/rulelibrary.ts","./src/services/architecturelint/types.ts","./src/services/architecturelint/workspacerules.ts","./src/services/collaboration/bootstrap.test.ts","./src/services/collaboration/bootstrap.ts","./src/services/collaboration/canvasdiff.test.ts","./src/services/collaboration/canvasdiff.ts","./src/services/collaboration/comments.test.ts","./src/services/collaboration/comments.ts","./src/services/collaboration/contracts.test.ts","./src/services/collaboration/contracts.ts","./src/services/collaboration/hookutils.ts","./src/services/collaboration/operationlog.test.ts","./src/services/collaboration/operationlog.ts","./src/services/collaboration/presenceviewmodel.test.ts","./src/services/collaboration/presenceviewmodel.ts","./src/services/collaboration/reducer.test.ts","./src/services/collaboration/reducer.ts","./src/services/collaboration/roomconfig.ts","./src/services/collaboration/roomlink.test.ts","./src/services/collaboration/roomlink.ts","./src/services/collaboration/runtimecontroller.test.ts","./src/services/collaboration/runtimecontroller.ts","./src/services/collaboration/runtimehookutils.test.ts","./src/services/collaboration/runtimehookutils.ts","./src/services/collaboration/schemas.ts","./src/services/collaboration/session.test.ts","./src/services/collaboration/session.ts","./src/services/collaboration/storebridge.test.ts","./src/services/collaboration/storebridge.ts","./src/services/collaboration/transport.test.ts","./src/services/collaboration/transport.ts","./src/services/collaboration/transportfactory.test.ts","./src/services/collaboration/transportfactory.ts","./src/services/collaboration/types.ts","./src/services/collaboration/versioning.test.ts","./src/services/collaboration/versioning.ts","./src/services/collaboration/yjspeertransport.test.ts","./src/services/collaboration/yjspeertransport.ts","./src/services/diagramdiff/diffengine.ts","./src/services/elk-layout/boundaryfanout.ts","./src/services/elk-layout/determinism.ts","./src/services/elk-layout/options.test.ts","./src/services/elk-layout/options.ts","./src/services/elk-layout/textsizing.ts","./src/services/elk-layout/types.ts","./src/services/export/cinematicbuildplan.test.ts","./src/services/export/cinematicbuildplan.ts","./src/services/export/cinematicexport.ts","./src/services/export/cinematicexporttheme.test.ts","./src/services/export/cinematicexporttheme.ts","./src/services/export/cinematicrenderstate.test.ts","./src/services/export/cinematicrenderstate.ts","./src/services/export/formatting.ts","./src/services/export/mermaidbuilder.test.ts","./src/services/export/mermaidbuilder.ts","./src/services/export/mermaidexportquality.test.ts","./src/services/export/pdfdocument.test.ts","./src/services/export/pdfdocument.ts","./src/services/export/plantumlbuilder.ts","./src/services/export/mermaid/architecturemermaid.ts","./src/services/export/mermaid/classdiagrammermaid.ts","./src/services/export/mermaid/erdiagrammermaid.ts","./src/services/export/mermaid/journeymermaid.ts","./src/services/export/mermaid/mindmapmermaid.ts","./src/services/export/mermaid/sequencemermaid.ts","./src/services/export/mermaid/statediagrammermaid.ts","./src/services/figma/edgehelpers.ts","./src/services/figma/iconhelpers.ts","./src/services/figma/nodelayers.ts","./src/services/figma/themehelpers.ts","./src/services/figmaimport/figmaapiclient.ts","./src/services/flowpilot/assetgrounding.test.ts","./src/services/flowpilot/assetgrounding.ts","./src/services/flowpilot/prompting.ts","./src/services/flowpilot/responsepolicy.test.ts","./src/services/flowpilot/responsepolicy.ts","./src/services/flowpilot/skills.ts","./src/services/flowpilot/thread.ts","./src/services/flowpilot/types.ts","./src/services/infrasync/dockercomposeparser.test.ts","./src/services/infrasync/dockercomposeparser.ts","./src/services/infrasync/infratodsl.test.ts","./src/services/infrasync/infratodsl.ts","./src/services/infrasync/kubernetesparser.test.ts","./src/services/infrasync/kubernetesparser.ts","./src/services/infrasync/terraformstateparser.test.ts","./src/services/infrasync/terraformstateparser.ts","./src/services/infrasync/types.ts","./src/services/mermaid/compatfixturecorpus.test.ts","./src/services/mermaid/compatreportharness.test.ts","./src/services/mermaid/detectdiagramtype.test.ts","./src/services/mermaid/detectdiagramtype.ts","./src/services/mermaid/diagnosticformatting.test.ts","./src/services/mermaid/diagnosticformatting.ts","./src/services/mermaid/diagnosticssnapshot.test.ts","./src/services/mermaid/diagnosticssnapshot.ts","./src/services/mermaid/editablepartialcorpus.test.ts","./src/services/mermaid/importcontracts.ts","./src/services/mermaid/importstatepresentation.test.ts","./src/services/mermaid/importstatepresentation.ts","./src/services/mermaid/mermaidlayoutcorpus.test.ts","./src/services/mermaid/officialmermaidvalidation.test.ts","./src/services/mermaid/officialmermaidvalidation.ts","./src/services/mermaid/parsemermaidbytype.test.ts","./src/services/mermaid/parsemermaidbytype.ts","./src/services/mermaid/strictmodediagnosticspresentation.test.ts","./src/services/mermaid/strictmodediagnosticspresentation.ts","./src/services/mermaid/strictmodeguidance.test.ts","./src/services/mermaid/strictmodeguidance.ts","./src/services/mermaid/strictmodeuxregression.test.ts","./src/services/mermaid/supportmatrix.test.ts","./src/services/mermaid/supportmatrix.ts","./src/services/offline/registerappshellserviceworker.test.ts","./src/services/offline/registerappshellserviceworker.ts","./src/services/onboarding/config.ts","./src/services/onboarding/eventschemas.ts","./src/services/onboarding/events.test.ts","./src/services/onboarding/events.ts","./src/services/playback/contracts.test.ts","./src/services/playback/contracts.ts","./src/services/playback/model.test.ts","./src/services/playback/model.ts","./src/services/playback/studio.test.ts","./src/services/playback/studio.ts","./src/services/sequence/layoutconstants.ts","./src/services/sequence/sequencemessage.test.ts","./src/services/sequence/sequencemessage.ts","./src/services/shapelibrary/bootstrap.test.ts","./src/services/shapelibrary/bootstrap.ts","./src/services/shapelibrary/ingestionpipeline.test.ts","./src/services/shapelibrary/ingestionpipeline.ts","./src/services/shapelibrary/manifestvalidation.test.ts","./src/services/shapelibrary/manifestvalidation.ts","./src/services/shapelibrary/providercatalog.test.ts","./src/services/shapelibrary/providercatalog.ts","./src/services/shapelibrary/registry.test.ts","./src/services/shapelibrary/registry.ts","./src/services/shapelibrary/starterpacks.ts","./src/services/shapelibrary/types.ts","./src/services/storage/fallbackstorage.ts","./src/services/storage/flowdocumentmodel.test.ts","./src/services/storage/flowdocumentmodel.ts","./src/services/storage/flowpersiststorage.test.ts","./src/services/storage/flowpersiststorage.ts","./src/services/storage/indexeddbhelpers.ts","./src/services/storage/indexeddbschema.test.ts","./src/services/storage/indexeddbschema.ts","./src/services/storage/indexeddbstatestorage.test.ts","./src/services/storage/indexeddbstatestorage.ts","./src/services/storage/localfirstrepository.ts","./src/services/storage/localfirstruntime.ts","./src/services/storage/persisteddocumentadapters.test.ts","./src/services/storage/persisteddocumentadapters.ts","./src/services/storage/persistencetypes.ts","./src/services/storage/snapshotstorage.test.ts","./src/services/storage/snapshotstorage.ts","./src/services/storage/storageruntime.test.ts","./src/services/storage/storageruntime.ts","./src/services/storage/storageschemas.test.ts","./src/services/storage/storageschemas.ts","./src/services/storage/storagetelemetry.test.ts","./src/services/storage/storagetelemetry.ts","./src/services/storage/storagetelemetrysink.test.ts","./src/services/storage/storagetelemetrysink.ts","./src/services/storage/uilocalstorage.test.ts","./src/services/storage/uilocalstorage.ts","./src/services/templatelibrary/registry.test.ts","./src/services/templatelibrary/registry.ts","./src/services/templatelibrary/startertemplates.assets.test.ts","./src/services/templatelibrary/startertemplates.test.ts","./src/services/templatelibrary/startertemplates.ts","./src/services/templatelibrary/templatefactories.ts","./src/services/templatelibrary/types.ts","./src/store/actionfactory.ts","./src/store/aisettings.test.ts","./src/store/aisettings.ts","./src/store/aisettingspersistence.ts","./src/store/aisettingsschemas.test.ts","./src/store/aisettingsschemas.ts","./src/store/canvashooks.ts","./src/store/createflowstore.test.ts","./src/store/createflowstore.ts","./src/store/createflowstorepersistoptions.ts","./src/store/createflowstorestate.ts","./src/store/defaults.ts","./src/store/designsystemhooks.ts","./src/store/documenthooks.ts","./src/store/documentstatesync.test.ts","./src/store/documentstatesync.ts","./src/store/editorpagehooks.ts","./src/store/historyhooks.ts","./src/store/historystate.ts","./src/store/persistence.test.ts","./src/store/persistence.ts","./src/store/persistenceschemas.ts","./src/store/selectionhooks.ts","./src/store/selectors.test.ts","./src/store/selectors.ts","./src/store/tabhooks.ts","./src/store/types.ts","./src/store/viewhooks.ts","./src/store/workspacedocumentmodel.test.ts","./src/store/workspacedocumentmodel.ts","./src/store/actions/createaiandselectionactions.ts","./src/store/actions/createcanvasactions.ts","./src/store/actions/createdesignsystemactions.ts","./src/store/actions/createhistoryactions.test.ts","./src/store/actions/createhistoryactions.ts","./src/store/actions/createlayeractions.ts","./src/store/actions/createtabactions.ts","./src/store/actions/createviewactions.ts","./src/store/actions/createworkspacedocumentactions.ts","./src/store/actions/synctabnodesedges.ts","./src/store/slices/createcanvaseditorslice.ts","./src/store/slices/createexperienceslice.ts","./src/store/slices/createworkspaceslice.ts"],"version":"5.8.3"}
\ No newline at end of file