@eventcatalog/core 3.10.1 → 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/analytics/analytics.cjs +1 -1
  2. package/dist/analytics/analytics.js +2 -2
  3. package/dist/analytics/log-build.cjs +1 -1
  4. package/dist/analytics/log-build.js +3 -3
  5. package/dist/{chunk-SABEVBE7.js → chunk-744TUGLY.js} +1 -1
  6. package/dist/{chunk-M3NSMBIJ.js → chunk-JGYH3AAT.js} +1 -1
  7. package/dist/{chunk-2MSR3Y2A.js → chunk-LHPQHOE5.js} +1 -1
  8. package/dist/{chunk-BR3CTGCW.js → chunk-Q6BRAXMP.js} +1 -1
  9. package/dist/{chunk-G7XOCNOX.js → chunk-TE67QRWX.js} +1 -1
  10. package/dist/constants.cjs +1 -1
  11. package/dist/constants.js +1 -1
  12. package/dist/eventcatalog.cjs +2 -1
  13. package/dist/eventcatalog.config.d.cts +5 -0
  14. package/dist/eventcatalog.config.d.ts +5 -0
  15. package/dist/eventcatalog.js +6 -5
  16. package/dist/generate.cjs +1 -1
  17. package/dist/generate.js +3 -3
  18. package/dist/utils/cli-logger.cjs +1 -1
  19. package/dist/utils/cli-logger.js +2 -2
  20. package/eventcatalog/integrations/eventcatalog-features.ts +13 -0
  21. package/eventcatalog/src/components/MDX/Design/Design.astro +10 -2
  22. package/eventcatalog/src/components/MDX/EntityMap/EntityMap.astro +10 -2
  23. package/eventcatalog/src/components/MDX/Flow/Flow.astro +10 -2
  24. package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +10 -2
  25. package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.tsx +146 -1
  26. package/eventcatalog/src/components/MDX/NodeGraph/VisualizerDropdownContent.tsx +91 -2
  27. package/eventcatalog/src/enterprise/visualizer-layout/reset.ts +45 -0
  28. package/eventcatalog/src/enterprise/visualizer-layout/save.ts +57 -0
  29. package/eventcatalog/src/pages/api/catalog.ts +12 -6
  30. package/eventcatalog/src/utils/feature.ts +2 -0
  31. package/eventcatalog/src/utils/node-graphs/layout-persistence.ts +81 -0
  32. package/package.json +1 -1
@@ -37,7 +37,7 @@ var import_axios = __toESM(require("axios"), 1);
37
37
  var import_os = __toESM(require("os"), 1);
38
38
 
39
39
  // package.json
40
- var version = "3.10.1";
40
+ var version = "3.11.0";
41
41
 
42
42
  // src/constants.ts
43
43
  var VERSION = version;
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  raiseEvent
3
- } from "../chunk-M3NSMBIJ.js";
4
- import "../chunk-SABEVBE7.js";
3
+ } from "../chunk-JGYH3AAT.js";
4
+ import "../chunk-744TUGLY.js";
5
5
  export {
6
6
  raiseEvent
7
7
  };
@@ -106,7 +106,7 @@ var import_axios = __toESM(require("axios"), 1);
106
106
  var import_os = __toESM(require("os"), 1);
107
107
 
108
108
  // package.json
109
- var version = "3.10.1";
109
+ var version = "3.11.0";
110
110
 
111
111
  // src/constants.ts
112
112
  var VERSION = version;
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  log_build_default
3
- } from "../chunk-2MSR3Y2A.js";
4
- import "../chunk-M3NSMBIJ.js";
3
+ } from "../chunk-LHPQHOE5.js";
4
+ import "../chunk-JGYH3AAT.js";
5
5
  import "../chunk-4UVFXLPI.js";
6
- import "../chunk-SABEVBE7.js";
6
+ import "../chunk-744TUGLY.js";
7
7
  import "../chunk-UPONRQSN.js";
8
8
  export {
9
9
  log_build_default as default
@@ -1,5 +1,5 @@
1
1
  // package.json
2
- var version = "3.10.1";
2
+ var version = "3.11.0";
3
3
 
4
4
  // src/constants.ts
5
5
  var VERSION = version;
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  VERSION
3
- } from "./chunk-SABEVBE7.js";
3
+ } from "./chunk-744TUGLY.js";
4
4
 
5
5
  // src/analytics/analytics.js
6
6
  import axios from "axios";
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  raiseEvent
3
- } from "./chunk-M3NSMBIJ.js";
3
+ } from "./chunk-JGYH3AAT.js";
4
4
  import {
5
5
  countResources,
6
6
  serializeCounts
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  logger
3
- } from "./chunk-G7XOCNOX.js";
3
+ } from "./chunk-TE67QRWX.js";
4
4
  import {
5
5
  cleanup,
6
6
  getEventCatalogConfigFile
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  VERSION
3
- } from "./chunk-SABEVBE7.js";
3
+ } from "./chunk-744TUGLY.js";
4
4
 
5
5
  // src/utils/cli-logger.ts
6
6
  import pc from "picocolors";
@@ -25,7 +25,7 @@ __export(constants_exports, {
25
25
  module.exports = __toCommonJS(constants_exports);
26
26
 
27
27
  // package.json
28
- var version = "3.10.1";
28
+ var version = "3.11.0";
29
29
 
30
30
  // src/constants.ts
31
31
  var VERSION = version;
package/dist/constants.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  VERSION
3
- } from "./chunk-SABEVBE7.js";
3
+ } from "./chunk-744TUGLY.js";
4
4
  export {
5
5
  VERSION
6
6
  };
@@ -109,7 +109,7 @@ var verifyRequiredFieldsAreInCatalogConfigFile = async (projectDirectory) => {
109
109
  var import_picocolors = __toESM(require("picocolors"), 1);
110
110
 
111
111
  // package.json
112
- var version = "3.10.1";
112
+ var version = "3.11.0";
113
113
 
114
114
  // src/constants.ts
115
115
  var VERSION = version;
@@ -812,6 +812,7 @@ program.command("dev").description("Run development server of EventCatalog").opt
812
812
  ENABLE_EMBED: canEmbedPages || isEventCatalogScale,
813
813
  EVENTCATALOG_STARTER: isEventCatalogStarter,
814
814
  EVENTCATALOG_SCALE: isEventCatalogScale,
815
+ EVENTCATALOG_DEV_MODE: "true",
815
816
  NODE_NO_WARNINGS: "1"
816
817
  }
817
818
  }
@@ -121,6 +121,11 @@ interface Config {
121
121
  sidebar?: (ManualSideBarConfig | AutoGeneratedSideBarConfig)[];
122
122
  };
123
123
  api?: {
124
+ /**
125
+ * Enable or disable the /api/catalog endpoint that dumps the entire catalog as JSON.
126
+ * Disabling this can significantly reduce memory usage during builds for large catalogs (1000+ files).
127
+ * @default true
128
+ */
124
129
  fullCatalogAPIEnabled?: boolean;
125
130
  };
126
131
  changelog?: {
@@ -121,6 +121,11 @@ interface Config {
121
121
  sidebar?: (ManualSideBarConfig | AutoGeneratedSideBarConfig)[];
122
122
  };
123
123
  api?: {
124
+ /**
125
+ * Enable or disable the /api/catalog endpoint that dumps the entire catalog as JSON.
126
+ * Disabling this can significantly reduce memory usage during builds for large catalogs (1000+ files).
127
+ * @default true
128
+ */
124
129
  fullCatalogAPIEnabled?: boolean;
125
130
  };
126
131
  changelog?: {
@@ -6,8 +6,8 @@ import {
6
6
  } from "./chunk-PLNJC7NZ.js";
7
7
  import {
8
8
  log_build_default
9
- } from "./chunk-2MSR3Y2A.js";
10
- import "./chunk-M3NSMBIJ.js";
9
+ } from "./chunk-LHPQHOE5.js";
10
+ import "./chunk-JGYH3AAT.js";
11
11
  import "./chunk-4UVFXLPI.js";
12
12
  import {
13
13
  runMigrations
@@ -22,13 +22,13 @@ import {
22
22
  } from "./chunk-5VBIXL6C.js";
23
23
  import {
24
24
  generate
25
- } from "./chunk-BR3CTGCW.js";
25
+ } from "./chunk-Q6BRAXMP.js";
26
26
  import {
27
27
  logger
28
- } from "./chunk-G7XOCNOX.js";
28
+ } from "./chunk-TE67QRWX.js";
29
29
  import {
30
30
  VERSION
31
- } from "./chunk-SABEVBE7.js";
31
+ } from "./chunk-744TUGLY.js";
32
32
  import "./chunk-UPONRQSN.js";
33
33
 
34
34
  // src/eventcatalog.ts
@@ -165,6 +165,7 @@ program.command("dev").description("Run development server of EventCatalog").opt
165
165
  ENABLE_EMBED: canEmbedPages || isEventCatalogScale,
166
166
  EVENTCATALOG_STARTER: isEventCatalogStarter,
167
167
  EVENTCATALOG_SCALE: isEventCatalogScale,
168
+ EVENTCATALOG_DEV_MODE: "true",
168
169
  NODE_NO_WARNINGS: "1"
169
170
  }
170
171
  }
package/dist/generate.cjs CHANGED
@@ -73,7 +73,7 @@ var getEventCatalogConfigFile = async (projectDirectory) => {
73
73
  var import_picocolors = __toESM(require("picocolors"), 1);
74
74
 
75
75
  // package.json
76
- var version = "3.10.1";
76
+ var version = "3.11.0";
77
77
 
78
78
  // src/constants.ts
79
79
  var VERSION = version;
package/dist/generate.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  generate
3
- } from "./chunk-BR3CTGCW.js";
4
- import "./chunk-G7XOCNOX.js";
5
- import "./chunk-SABEVBE7.js";
3
+ } from "./chunk-Q6BRAXMP.js";
4
+ import "./chunk-TE67QRWX.js";
5
+ import "./chunk-744TUGLY.js";
6
6
  import "./chunk-UPONRQSN.js";
7
7
  export {
8
8
  generate
@@ -36,7 +36,7 @@ module.exports = __toCommonJS(cli_logger_exports);
36
36
  var import_picocolors = __toESM(require("picocolors"), 1);
37
37
 
38
38
  // package.json
39
- var version = "3.10.1";
39
+ var version = "3.11.0";
40
40
 
41
41
  // src/constants.ts
42
42
  var VERSION = version;
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  logger
3
- } from "../chunk-G7XOCNOX.js";
4
- import "../chunk-SABEVBE7.js";
3
+ } from "../chunk-TE67QRWX.js";
4
+ import "../chunk-744TUGLY.js";
5
5
  export {
6
6
  logger
7
7
  };
@@ -6,6 +6,7 @@ import {
6
6
  isEventCatalogScaleEnabled,
7
7
  isEventCatalogStarterEnabled,
8
8
  isEventCatalogMCPEnabled,
9
+ isDevMode,
9
10
  } from '../src/utils/feature';
10
11
 
11
12
  const catalogDirectory = process.env.CATALOG_DIR || process.cwd();
@@ -72,6 +73,18 @@ export default function eventCatalogIntegration(): AstroIntegration {
72
73
  entrypoint: path.join(catalogDirectory, 'src/enterprise/plans/index.astro'),
73
74
  });
74
75
  }
76
+
77
+ // Dev-only routes for visualizer layout persistence
78
+ if (isDevMode()) {
79
+ params.injectRoute({
80
+ pattern: '/api/dev/visualizer-layout/save',
81
+ entrypoint: path.join(catalogDirectory, 'src/enterprise/visualizer-layout/save.ts'),
82
+ });
83
+ params.injectRoute({
84
+ pattern: '/api/dev/visualizer-layout/reset',
85
+ entrypoint: path.join(catalogDirectory, 'src/enterprise/visualizer-layout/reset.ts'),
86
+ });
87
+ }
75
88
  },
76
89
  },
77
90
  };
@@ -5,7 +5,8 @@ import { getAbsoluteFilePathForAstroFile } from '@utils/files';
5
5
  import Admonition from '@components/MDX/Admonition';
6
6
  import NodeGraph from '../NodeGraph/NodeGraph';
7
7
 
8
- import { isVisualiserEnabled, isEventCatalogChatEnabled } from '@utils/feature';
8
+ import { isVisualiserEnabled, isEventCatalogChatEnabled, isDevMode } from '@utils/feature';
9
+ import { loadSavedLayout, applyLayoutToNodes, buildResourceKey } from '@utils/node-graphs/layout-persistence';
9
10
 
10
11
  const isChatEnabled = isEventCatalogChatEnabled();
11
12
 
@@ -21,6 +22,11 @@ try {
21
22
  } catch (error) {
22
23
  console.error(`Error reading design file: ${error}`);
23
24
  }
25
+
26
+ // Load and apply saved layout if it exists
27
+ const resourceKey = design ? buildResourceKey('designs', design.id || id) : '';
28
+ const savedLayout = resourceKey ? await loadSavedLayout(resourceKey) : null;
29
+ const nodesWithLayout = design ? applyLayoutToNodes(design.nodes || [], savedLayout) : ([] as any[]);
24
30
  ---
25
31
 
26
32
  {
@@ -51,7 +57,7 @@ try {
51
57
  <NodeGraph
52
58
  id={id}
53
59
  title={title ?? design.name}
54
- nodes={design.nodes || []}
60
+ nodes={nodesWithLayout}
55
61
  edges={design.edges || []}
56
62
  hrefLabel={isVisualiserEnabled() ? 'View in visualizer' : undefined}
57
63
  href={isVisualiserEnabled() ? `/visualiser/designs/${design.id}` : undefined}
@@ -60,6 +66,8 @@ try {
60
66
  client:only="react"
61
67
  showSearch={search}
62
68
  isChatEnabled={isChatEnabled}
69
+ isDevMode={isDevMode()}
70
+ resourceKey={resourceKey}
63
71
  />
64
72
  </div>
65
73
  </div>
@@ -5,7 +5,8 @@ import Admonition from '@components/MDX/Admonition';
5
5
  import NodeGraph from '../NodeGraph/NodeGraph';
6
6
  import { getVersionFromCollection } from '@utils/collections/versions';
7
7
  import { getServices } from '@utils/collections/services';
8
- import { isEventCatalogChatEnabled } from '@utils/feature';
8
+ import { isEventCatalogChatEnabled, isDevMode } from '@utils/feature';
9
+ import { loadSavedLayout, applyLayoutToNodes, buildResourceKey } from '@utils/node-graphs/layout-persistence';
9
10
 
10
11
  const isChatEnabled = isEventCatalogChatEnabled();
11
12
 
@@ -38,6 +39,11 @@ const { nodes, edges } = await getNodesAndEdges({
38
39
  ...(entities ? { entities } : {}), // Pass entities if provided
39
40
  type: collection,
40
41
  });
42
+
43
+ // Load and apply saved layout if it exists
44
+ const resourceKey = buildResourceKey(collection, resourceId, resource?.data?.version);
45
+ const savedLayout = await loadSavedLayout(resourceKey);
46
+ const nodesWithLayout = applyLayoutToNodes(nodes as any[], savedLayout);
41
47
  ---
42
48
 
43
49
  {
@@ -66,7 +72,7 @@ const { nodes, edges } = await getNodesAndEdges({
66
72
  <div>
67
73
  <NodeGraph
68
74
  id={id}
69
- nodes={nodes}
75
+ nodes={nodesWithLayout}
70
76
  edges={edges}
71
77
  linkTo={'visualiser'}
72
78
  mode="simple"
@@ -75,6 +81,8 @@ const { nodes, edges } = await getNodesAndEdges({
75
81
  client:only="react"
76
82
  portalId={`${id}-entity-map-portal`}
77
83
  isChatEnabled={isChatEnabled}
84
+ isDevMode={isDevMode()}
85
+ resourceKey={resourceKey}
78
86
  />
79
87
  </div>
80
88
 
@@ -4,7 +4,8 @@ import { getNodesAndEdges } from '@utils/node-graphs/flows-node-graph';
4
4
  import Admonition from '@components/MDX/Admonition';
5
5
  import NodeGraph from '../NodeGraph/NodeGraph';
6
6
  import { getVersionFromCollection } from '@utils/collections/versions';
7
- import { isVisualiserEnabled, isEventCatalogChatEnabled } from '@utils/feature';
7
+ import { isVisualiserEnabled, isEventCatalogChatEnabled, isDevMode } from '@utils/feature';
8
+ import { loadSavedLayout, applyLayoutToNodes, buildResourceKey } from '@utils/node-graphs/layout-persistence';
8
9
 
9
10
  const isChatEnabled = isEventCatalogChatEnabled();
10
11
 
@@ -22,6 +23,11 @@ const { nodes, edges } = await getNodesAndEdges({
22
23
  version: flow.data.version,
23
24
  mode: mode,
24
25
  });
26
+
27
+ // Load and apply saved layout if it exists
28
+ const resourceKey = buildResourceKey('flows', id, flow.data.version);
29
+ const savedLayout = await loadSavedLayout(resourceKey);
30
+ const nodesWithLayout = applyLayoutToNodes(nodes, savedLayout);
25
31
  ---
26
32
 
27
33
  {
@@ -49,7 +55,7 @@ const { nodes, edges } = await getNodesAndEdges({
49
55
  <div>
50
56
  <NodeGraph
51
57
  id={id}
52
- nodes={nodes}
58
+ nodes={nodesWithLayout}
53
59
  edges={edges}
54
60
  hrefLabel={'View in visualizer'}
55
61
  href={isVisualiserEnabled() ? `/visualiser/flows/${id}/${version}` : undefined}
@@ -60,6 +66,8 @@ const { nodes, edges } = await getNodesAndEdges({
60
66
  showFlowWalkthrough={walkthrough}
61
67
  showSearch={search}
62
68
  isChatEnabled={isChatEnabled}
69
+ isDevMode={isDevMode()}
70
+ resourceKey={resourceKey}
63
71
  />
64
72
  </div>
65
73
 
@@ -19,7 +19,8 @@ import { getVersionFromCollection } from '@utils/collections/versions';
19
19
  import { pageDataLoader } from '@utils/page-loaders/page-data-loader';
20
20
  import { getNodesAndEdges as getNodesAndEdgesForContainer } from '@utils/node-graphs/container-node-graph';
21
21
  import config from '@config';
22
- import { isEventCatalogChatEnabled } from '@utils/feature';
22
+ import { isEventCatalogChatEnabled, isDevMode } from '@utils/feature';
23
+ import { loadSavedLayout, applyLayoutToNodes, buildResourceKey } from '@utils/node-graphs/layout-persistence';
23
24
 
24
25
  const isChatEnabled = isEventCatalogChatEnabled();
25
26
 
@@ -139,12 +140,17 @@ if (collection === 'services-containers') {
139
140
  nodes = fetchedNodes;
140
141
  edges = fetchedEdges;
141
142
  }
143
+
144
+ // Load and apply saved layout if it exists (dev mode feature)
145
+ const resourceKey = buildResourceKey(collection, id, version);
146
+ const savedLayout = await loadSavedLayout(resourceKey);
147
+ const nodesWithLayout = applyLayoutToNodes(nodes as any[], savedLayout);
142
148
  ---
143
149
 
144
150
  <div>
145
151
  <NodeGraphNew
146
152
  id={id}
147
- nodes={nodes}
153
+ nodes={nodesWithLayout}
148
154
  edges={edges}
149
155
  title={title}
150
156
  hrefLabel={href?.label}
@@ -159,6 +165,8 @@ if (collection === 'services-containers') {
159
165
  zoomOnScroll={zoomOnScroll}
160
166
  isChatEnabled={isChatEnabled}
161
167
  maxTextSize={config.mermaid?.maxTextSize}
168
+ isDevMode={isDevMode()}
169
+ resourceKey={resourceKey}
162
170
  />
163
171
  </div>
164
172
 
@@ -12,6 +12,7 @@ import {
12
12
  useEdgesState,
13
13
  type Edge,
14
14
  type Node,
15
+ type NodeChange,
15
16
  useReactFlow,
16
17
  getNodesBounds,
17
18
  getViewportForBounds,
@@ -58,6 +59,9 @@ import VisualizerDropdownContent from './VisualizerDropdownContent';
58
59
  import { convertToMermaid } from '@utils/node-graphs/export-mermaid';
59
60
  import { copyToClipboard } from '@utils/clipboard';
60
61
 
62
+ // Minimum pixel change to detect layout modifications (avoids floating point comparison issues)
63
+ const POSITION_CHANGE_THRESHOLD = 1;
64
+
61
65
  interface Props {
62
66
  nodes: any;
63
67
  edges: any;
@@ -78,6 +82,8 @@ interface Props {
78
82
  setIsStudioModalOpen?: (isOpen: boolean) => void;
79
83
  isChatEnabled?: boolean;
80
84
  maxTextSize?: number;
85
+ isDevMode?: boolean;
86
+ resourceKey?: string;
81
87
  }
82
88
 
83
89
  const getVisualiserUrlForCollection = (collectionItem: CollectionEntry<CollectionTypes>) => {
@@ -101,6 +107,8 @@ const NodeGraphBuilder = ({
101
107
  setIsStudioModalOpen = () => {},
102
108
  isChatEnabled = false,
103
109
  maxTextSize,
110
+ isDevMode = false,
111
+ resourceKey,
104
112
  }: Props) => {
105
113
  const nodeTypes = useMemo(
106
114
  () =>
@@ -153,6 +161,9 @@ const NodeGraphBuilder = ({
153
161
  const [shareUrlCopySuccess, setShareUrlCopySuccess] = useState(false);
154
162
  const [isMermaidView, setIsMermaidView] = useState(false);
155
163
  const [showMinimap, setShowMinimap] = useState(false);
164
+ const [hasLayoutChanges, setHasLayoutChanges] = useState(false);
165
+ const [isSavingLayout, setIsSavingLayout] = useState(false);
166
+ const initialPositionsRef = useRef<Record<string, { x: number; y: number }>>({});
156
167
  // const [isStudioModalOpen, setIsStudioModalOpen] = useState(false);
157
168
 
158
169
  // Check if there are channels to determine if we need the visualizer functionality
@@ -169,6 +180,49 @@ const NodeGraphBuilder = ({
169
180
  const reactFlowWrapperRef = useRef<HTMLDivElement>(null);
170
181
  const scrollableContainerRef = useRef<HTMLElement | null>(null);
171
182
 
183
+ // Store initial node positions for change detection (dev mode only)
184
+ useEffect(() => {
185
+ if (isDevMode && initialNodes.length > 0) {
186
+ const positions: Record<string, { x: number; y: number }> = {};
187
+ initialNodes.forEach((node: Node) => {
188
+ positions[node.id] = { x: node.position.x, y: node.position.y };
189
+ });
190
+ initialPositionsRef.current = positions;
191
+ }
192
+ }, [isDevMode, initialNodes]);
193
+
194
+ // Detect layout changes by comparing current positions to initial positions
195
+ const checkForLayoutChanges = useCallback(() => {
196
+ if (!isDevMode) return;
197
+ const initial = initialPositionsRef.current;
198
+ if (Object.keys(initial).length === 0) return;
199
+
200
+ const hasChanges = nodes.some((node) => {
201
+ const initialPos = initial[node.id];
202
+ return (
203
+ initialPos &&
204
+ (Math.abs(node.position.x - initialPos.x) > POSITION_CHANGE_THRESHOLD ||
205
+ Math.abs(node.position.y - initialPos.y) > POSITION_CHANGE_THRESHOLD)
206
+ );
207
+ });
208
+
209
+ setHasLayoutChanges(hasChanges);
210
+ }, [isDevMode, nodes]);
211
+
212
+ // Wrap onNodesChange to detect layout changes after node drag
213
+ const handleNodesChange = useCallback(
214
+ (changes: NodeChange[]) => {
215
+ onNodesChange(changes);
216
+ // Check for position changes after drag ends
217
+ const hasDragEnd = changes.some((change) => change.type === 'position' && !change.dragging);
218
+ if (hasDragEnd) {
219
+ // Use setTimeout to ensure state is updated
220
+ setTimeout(checkForLayoutChanges, 0);
221
+ }
222
+ },
223
+ [onNodesChange, checkForLayoutChanges]
224
+ );
225
+
172
226
  const resetNodesAndEdges = useCallback(() => {
173
227
  setNodes((nds) =>
174
228
  nds.map((node) => {
@@ -397,6 +451,63 @@ const NodeGraphBuilder = ({
397
451
  window.dispatchEvent(new CustomEvent('eventcatalog:open-chat'));
398
452
  }, []);
399
453
 
454
+ // Layout persistence handlers (dev mode only)
455
+ const handleSaveLayout = useCallback(async (): Promise<boolean> => {
456
+ if (!resourceKey) return false;
457
+
458
+ const positions: Record<string, { x: number; y: number }> = {};
459
+ nodes.forEach((node) => {
460
+ positions[node.id] = {
461
+ x: node.position.x,
462
+ y: node.position.y,
463
+ };
464
+ });
465
+
466
+ try {
467
+ const response = await fetch('/api/dev/visualizer-layout/save', {
468
+ method: 'POST',
469
+ headers: { 'Content-Type': 'application/json' },
470
+ body: JSON.stringify({ resourceKey, positions }),
471
+ });
472
+ const result = await response.json();
473
+ return result.success === true;
474
+ } catch {
475
+ return false;
476
+ }
477
+ }, [nodes, resourceKey]);
478
+
479
+ const handleResetLayout = useCallback(async (): Promise<boolean> => {
480
+ if (!resourceKey) return false;
481
+
482
+ try {
483
+ const response = await fetch('/api/dev/visualizer-layout/reset', {
484
+ method: 'POST',
485
+ headers: { 'Content-Type': 'application/json' },
486
+ body: JSON.stringify({ resourceKey }),
487
+ });
488
+ const result = await response.json();
489
+ return result.success === true;
490
+ } catch {
491
+ return false;
492
+ }
493
+ }, [resourceKey]);
494
+
495
+ // Quick save handler for the change detection UI
496
+ const handleQuickSaveLayout = useCallback(async () => {
497
+ setIsSavingLayout(true);
498
+ const success = await handleSaveLayout();
499
+ setIsSavingLayout(false);
500
+ if (success) {
501
+ // Update initial positions to current positions after save
502
+ const positions: Record<string, { x: number; y: number }> = {};
503
+ nodes.forEach((node) => {
504
+ positions[node.id] = { x: node.position.x, y: node.position.y };
505
+ });
506
+ initialPositionsRef.current = positions;
507
+ setHasLayoutChanges(false);
508
+ }
509
+ }, [handleSaveLayout, nodes]);
510
+
400
511
  const handleCopyArchitectureCode = useCallback(async () => {
401
512
  await copyToClipboard(mermaidCode);
402
513
  }, [mermaidCode]);
@@ -692,6 +803,9 @@ const NodeGraphBuilder = ({
692
803
  setIsShareModalOpen={setIsShareModalOpen}
693
804
  toggleFullScreen={toggleFullScreen}
694
805
  openStudioModal={openStudioModal}
806
+ isDevMode={isDevMode}
807
+ onSaveLayout={handleSaveLayout}
808
+ onResetLayout={handleResetLayout}
695
809
  />
696
810
  </DropdownMenu.Content>
697
811
  </DropdownMenu.Portal>
@@ -720,7 +834,7 @@ const NodeGraphBuilder = ({
720
834
  nodes={nodes}
721
835
  edges={edges}
722
836
  fitView
723
- onNodesChange={onNodesChange}
837
+ onNodesChange={handleNodesChange}
724
838
  onEdgesChange={onEdgesChange}
725
839
  connectionLineType={ConnectionLineType.SmoothStep}
726
840
  nodeOrigin={[0.1, 0.1]}
@@ -772,6 +886,9 @@ const NodeGraphBuilder = ({
772
886
  setIsShareModalOpen={setIsShareModalOpen}
773
887
  toggleFullScreen={toggleFullScreen}
774
888
  openStudioModal={openStudioModal}
889
+ isDevMode={isDevMode}
890
+ onSaveLayout={handleSaveLayout}
891
+ onResetLayout={handleResetLayout}
775
892
  />
776
893
  </DropdownMenu.Content>
777
894
  </DropdownMenu.Portal>
@@ -845,6 +962,28 @@ const NodeGraphBuilder = ({
845
962
  />
846
963
  </Panel>
847
964
  )}
965
+ {/* Dev Mode: Layout change indicator */}
966
+ {isDevMode && hasLayoutChanges && (
967
+ <Panel
968
+ position="bottom-left"
969
+ style={
970
+ isFlowVisualization && showFlowWalkthrough
971
+ ? { marginBottom: '20px', marginLeft: '410px' }
972
+ : { marginLeft: '60px' }
973
+ }
974
+ >
975
+ <div className="bg-[rgb(var(--ec-card-bg))] border border-[rgb(var(--ec-page-border))] rounded-lg shadow-md px-3 py-2 flex items-center gap-3">
976
+ <span className="text-xs text-[rgb(var(--ec-page-text-muted))]">Layout changed</span>
977
+ <button
978
+ onClick={handleQuickSaveLayout}
979
+ disabled={isSavingLayout}
980
+ className="text-xs font-medium text-[rgb(var(--ec-accent-text))] bg-[rgb(var(--ec-accent-subtle))] hover:bg-[rgb(var(--ec-accent-subtle)/0.7)] px-2 py-1 rounded transition-colors disabled:opacity-50"
981
+ >
982
+ {isSavingLayout ? 'Saving...' : 'Save'}
983
+ </button>
984
+ </div>
985
+ </Panel>
986
+ )}
848
987
  {includeKey && (
849
988
  <Panel position="bottom-right" style={showMinimap ? { marginRight: '230px' } : undefined}>
850
989
  <div className=" bg-white font-light px-4 text-[12px] shadow-md py-1 rounded-md">
@@ -952,6 +1091,8 @@ interface NodeGraphProps {
952
1091
  designId?: string;
953
1092
  isChatEnabled?: boolean;
954
1093
  maxTextSize?: number;
1094
+ isDevMode?: boolean;
1095
+ resourceKey?: string;
955
1096
  }
956
1097
 
957
1098
  const NodeGraph = ({
@@ -974,6 +1115,8 @@ const NodeGraph = ({
974
1115
  designId,
975
1116
  isChatEnabled = false,
976
1117
  maxTextSize,
1118
+ isDevMode = false,
1119
+ resourceKey,
977
1120
  }: NodeGraphProps) => {
978
1121
  const [elem, setElem] = useState(null);
979
1122
  const [showFooter, setShowFooter] = useState(true);
@@ -1021,6 +1164,8 @@ const NodeGraph = ({
1021
1164
  setIsStudioModalOpen={setIsStudioModalOpen}
1022
1165
  isChatEnabled={isChatEnabled}
1023
1166
  maxTextSize={maxTextSize}
1167
+ isDevMode={isDevMode}
1168
+ resourceKey={resourceKey}
1024
1169
  />
1025
1170
 
1026
1171
  {showFooter && (
@@ -1,6 +1,20 @@
1
- import React, { type RefObject } from 'react';
1
+ import React, { type RefObject, useState } from 'react';
2
2
  import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
3
- import { Code, Share2, Search, Grid3x3, Maximize2, Map, Sparkles, Zap, EyeOff, ExternalLink } from 'lucide-react';
3
+ import {
4
+ Code,
5
+ Share2,
6
+ Search,
7
+ Grid3x3,
8
+ Maximize2,
9
+ Map,
10
+ Sparkles,
11
+ Zap,
12
+ EyeOff,
13
+ ExternalLink,
14
+ Save,
15
+ RotateCcw,
16
+ Loader2,
17
+ } from 'lucide-react';
4
18
  import { DocumentArrowDownIcon, PresentationChartLineIcon } from '@heroicons/react/24/outline';
5
19
  import type { VisualiserSearchRef } from './VisualiserSearch';
6
20
 
@@ -23,6 +37,9 @@ interface VisualizerDropdownContentProps {
23
37
  setIsShareModalOpen: (value: boolean) => void;
24
38
  toggleFullScreen: () => void;
25
39
  openStudioModal: () => void;
40
+ isDevMode?: boolean;
41
+ onSaveLayout?: () => Promise<boolean>;
42
+ onResetLayout?: () => Promise<boolean>;
26
43
  }
27
44
 
28
45
  const VisualizerDropdownContent: React.FC<VisualizerDropdownContentProps> = ({
@@ -44,7 +61,33 @@ const VisualizerDropdownContent: React.FC<VisualizerDropdownContentProps> = ({
44
61
  setIsShareModalOpen,
45
62
  toggleFullScreen,
46
63
  openStudioModal,
64
+ isDevMode = false,
65
+ onSaveLayout,
66
+ onResetLayout,
47
67
  }) => {
68
+ const [layoutStatus, setLayoutStatus] = useState<'idle' | 'saving' | 'resetting'>('idle');
69
+
70
+ const handleSaveLayout = async () => {
71
+ if (!onSaveLayout) return;
72
+ setLayoutStatus('saving');
73
+ await onSaveLayout();
74
+ setLayoutStatus('idle');
75
+ };
76
+
77
+ const handleResetLayout = async () => {
78
+ if (!onResetLayout) return;
79
+ if (!window.confirm('Reset layout to auto-positioning? This will delete your saved layout.')) {
80
+ return;
81
+ }
82
+ setLayoutStatus('resetting');
83
+ const success = await onResetLayout();
84
+ if (success) {
85
+ window.location.reload();
86
+ } else {
87
+ setLayoutStatus('idle');
88
+ }
89
+ };
90
+
48
91
  return (
49
92
  <>
50
93
  {/* Canvas Settings Submenu */}
@@ -157,6 +200,52 @@ const VisualizerDropdownContent: React.FC<VisualizerDropdownContentProps> = ({
157
200
  </DropdownMenu.Portal>
158
201
  </DropdownMenu.Sub>
159
202
 
203
+ {/* Dev Mode: Layout Submenu */}
204
+ {isDevMode && onSaveLayout && (
205
+ <DropdownMenu.Sub>
206
+ <DropdownMenu.SubTrigger className="flex items-center px-3 py-2 text-xs text-[rgb(var(--ec-page-text))] hover:bg-[rgb(var(--ec-accent-subtle)/0.3)] cursor-pointer transition-colors gap-2 outline-none">
207
+ <Save className="w-3.5 h-3.5 text-[rgb(var(--ec-page-text-muted))] flex-shrink-0" />
208
+ <span className="flex-1 font-normal">Layout</span>
209
+ <span className="text-[10px] text-amber-600 font-medium">DEV</span>
210
+ <svg className="w-3 h-3 text-[rgb(var(--ec-page-text-muted))]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
211
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
212
+ </svg>
213
+ </DropdownMenu.SubTrigger>
214
+ <DropdownMenu.Portal>
215
+ <DropdownMenu.SubContent
216
+ className="min-w-[180px] bg-[rgb(var(--ec-card-bg))] rounded-lg shadow-xl border border-[rgb(var(--ec-page-border))] py-1.5 z-[60]"
217
+ sideOffset={8}
218
+ alignOffset={-8}
219
+ >
220
+ <DropdownMenu.Item
221
+ onClick={handleSaveLayout}
222
+ disabled={layoutStatus !== 'idle'}
223
+ className="px-3 py-2 text-xs text-[rgb(var(--ec-page-text))] hover:bg-[rgb(var(--ec-accent-subtle)/0.3)] cursor-pointer flex items-center gap-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
224
+ >
225
+ {layoutStatus === 'saving' ? (
226
+ <Loader2 className="w-3.5 h-3.5 text-[rgb(var(--ec-page-text-muted))] flex-shrink-0 animate-spin" />
227
+ ) : (
228
+ <Save className="w-3.5 h-3.5 text-[rgb(var(--ec-page-text-muted))] flex-shrink-0" />
229
+ )}
230
+ <span className="flex-1 font-normal">{layoutStatus === 'saving' ? 'Saving...' : 'Save Layout'}</span>
231
+ </DropdownMenu.Item>
232
+ <DropdownMenu.Item
233
+ onClick={handleResetLayout}
234
+ disabled={layoutStatus !== 'idle'}
235
+ className="px-3 py-2 text-xs text-[rgb(var(--ec-page-text))] hover:bg-[rgb(var(--ec-accent-subtle)/0.3)] cursor-pointer flex items-center gap-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
236
+ >
237
+ {layoutStatus === 'resetting' ? (
238
+ <Loader2 className="w-3.5 h-3.5 text-[rgb(var(--ec-page-text-muted))] flex-shrink-0 animate-spin" />
239
+ ) : (
240
+ <RotateCcw className="w-3.5 h-3.5 text-[rgb(var(--ec-page-text-muted))] flex-shrink-0" />
241
+ )}
242
+ <span className="flex-1 font-normal">{layoutStatus === 'resetting' ? 'Resetting...' : 'Reset Layout'}</span>
243
+ </DropdownMenu.Item>
244
+ </DropdownMenu.SubContent>
245
+ </DropdownMenu.Portal>
246
+ </DropdownMenu.Sub>
247
+ )}
248
+
160
249
  {/* Ask AI */}
161
250
  {isChatEnabled && (
162
251
  <>
@@ -0,0 +1,45 @@
1
+ import type { APIRoute } from 'astro';
2
+ import fs from 'node:fs';
3
+ import { getLayoutFilePath } from '@utils/node-graphs/layout-persistence';
4
+
5
+ export const POST: APIRoute = async ({ request }) => {
6
+ try {
7
+ const body = await request.json();
8
+ const { resourceKey } = body;
9
+
10
+ // Validate input
11
+ if (!resourceKey) {
12
+ return new Response(JSON.stringify({ error: 'Missing required field: resourceKey' }), {
13
+ status: 400,
14
+ headers: { 'Content-Type': 'application/json' },
15
+ });
16
+ }
17
+
18
+ const filePath = getLayoutFilePath(resourceKey);
19
+
20
+ // Check if file exists
21
+ try {
22
+ await fs.promises.access(filePath);
23
+ } catch {
24
+ // File doesn't exist, nothing to delete
25
+ return new Response(JSON.stringify({ success: true, message: 'No saved layout to reset' }), {
26
+ headers: { 'Content-Type': 'application/json' },
27
+ });
28
+ }
29
+
30
+ // Delete the layout file
31
+ await fs.promises.unlink(filePath);
32
+
33
+ return new Response(JSON.stringify({ success: true, message: 'Layout reset successfully' }), {
34
+ headers: { 'Content-Type': 'application/json' },
35
+ });
36
+ } catch (error) {
37
+ const message = error instanceof Error ? error.message : 'Unknown error';
38
+ return new Response(JSON.stringify({ error: `Failed to reset layout: ${message}` }), {
39
+ status: 500,
40
+ headers: { 'Content-Type': 'application/json' },
41
+ });
42
+ }
43
+ };
44
+
45
+ export const prerender = false;
@@ -0,0 +1,57 @@
1
+ import type { APIRoute } from 'astro';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { getLayoutFilePath, type SavedLayout } from '@utils/node-graphs/layout-persistence';
5
+
6
+ export const POST: APIRoute = async ({ request }) => {
7
+ try {
8
+ const body = await request.json();
9
+ const { resourceKey, positions } = body;
10
+
11
+ // Validate input
12
+ if (!resourceKey || !positions) {
13
+ return new Response(JSON.stringify({ error: 'Missing required fields: resourceKey and positions' }), {
14
+ status: 400,
15
+ headers: { 'Content-Type': 'application/json' },
16
+ });
17
+ }
18
+
19
+ // Validate resourceKey doesn't contain path traversal
20
+ if (resourceKey.includes('..') || resourceKey.includes('~')) {
21
+ return new Response(JSON.stringify({ error: 'Invalid resourceKey' }), {
22
+ status: 400,
23
+ headers: { 'Content-Type': 'application/json' },
24
+ });
25
+ }
26
+
27
+ // Build file path from resource key using shared utility
28
+ const filePath = getLayoutFilePath(resourceKey);
29
+
30
+ // Create directory structure if needed
31
+ const fileDir = path.dirname(filePath);
32
+ await fs.promises.mkdir(fileDir, { recursive: true });
33
+
34
+ // Build layout data
35
+ const layoutData: SavedLayout = {
36
+ version: 1,
37
+ savedAt: new Date().toISOString(),
38
+ resourceKey,
39
+ positions,
40
+ };
41
+
42
+ // Write file with pretty formatting
43
+ await fs.promises.writeFile(filePath, JSON.stringify(layoutData, null, 2));
44
+
45
+ return new Response(JSON.stringify({ success: true, filePath }), {
46
+ headers: { 'Content-Type': 'application/json' },
47
+ });
48
+ } catch (error) {
49
+ const message = error instanceof Error ? error.message : 'Unknown error';
50
+ return new Response(JSON.stringify({ error: `Failed to save layout: ${message}` }), {
51
+ status: 500,
52
+ headers: { 'Content-Type': 'application/json' },
53
+ });
54
+ }
55
+ };
56
+
57
+ export const prerender = false;
@@ -2,18 +2,20 @@ import type { APIRoute } from 'astro';
2
2
  import utils from '@eventcatalog/sdk';
3
3
  import config from '@config';
4
4
 
5
+ const isFullCatalogAPIEnabled = config.api?.fullCatalogAPIEnabled ?? true;
6
+
5
7
  /**
6
- * Route the will dump the whole catalog as JSON (without markdown)
8
+ * Route that dumps the whole catalog as JSON (without markdown)
7
9
  * Experimental API
8
- * @param param0
9
- * @returns
10
+ *
11
+ * Can be disabled via eventcatalog.config.js:
12
+ * api: { fullCatalogAPIEnabled: false }
10
13
  */
11
- export const GET: APIRoute = async ({ params, request }) => {
12
- const isFullCatalogAPIEnabled = config.api?.fullCatalogAPIEnabled ?? true;
13
-
14
+ export const GET: APIRoute = async () => {
14
15
  if (!isFullCatalogAPIEnabled) {
15
16
  return new Response(JSON.stringify({ error: 'Full catalog API is not enabled' }), {
16
17
  status: 404,
18
+ headers: { 'Content-Type': 'application/json' },
17
19
  });
18
20
  }
19
21
 
@@ -26,3 +28,7 @@ export const GET: APIRoute = async ({ params, request }) => {
26
28
  },
27
29
  });
28
30
  };
31
+
32
+ // Only prerender if the API is enabled - this avoids loading all catalog data during build
33
+ // when the feature is disabled, saving memory for large catalogs
34
+ export const prerender = isFullCatalogAPIEnabled;
@@ -71,3 +71,5 @@ export const isCustomStylesEnabled = () => {
71
71
  export const isDiagramComparisonEnabled = () => isEventCatalogScaleEnabled();
72
72
 
73
73
  export const isEventCatalogMCPEnabled = () => isEventCatalogScaleEnabled() && isSSR();
74
+
75
+ export const isDevMode = () => process.env.EVENTCATALOG_DEV_MODE === 'true';
@@ -0,0 +1,81 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export interface SavedPosition {
5
+ x: number;
6
+ y: number;
7
+ }
8
+
9
+ export interface SavedLayout {
10
+ version: number;
11
+ savedAt: string;
12
+ resourceKey: string;
13
+ positions: Record<string, SavedPosition>;
14
+ }
15
+
16
+ /**
17
+ * Builds a resource key for layout persistence from collection, id, and version.
18
+ * e.g., buildResourceKey('services', 'OrderService', '1.0.0') -> 'services/OrderService/1.0.0'
19
+ */
20
+ export function buildResourceKey(collection: string, id: string, version?: string): string {
21
+ if (version) {
22
+ return `${collection}/${id}/${version}`;
23
+ }
24
+ return `${collection}/${id}`;
25
+ }
26
+
27
+ /**
28
+ * Converts a resource key to a file path for the saved layout
29
+ * e.g., services/OrderService/1.0.0 -> _data/visualizer-layouts/services/OrderService/1.0.0.json
30
+ * Uses path.join with spread segments for cross-platform compatibility (Windows/Mac/Linux)
31
+ */
32
+ export function getLayoutFilePath(resourceKey: string): string {
33
+ const PROJECT_DIR = process.env.PROJECT_DIR || process.cwd();
34
+ const cleanKey = resourceKey.replace(/^\//, '').replace(/\/$/, '');
35
+ // Split path segments and rejoin with platform-specific separator
36
+ const segments = cleanKey.split('/').filter(Boolean);
37
+ const fileName = `${segments.pop()}.json`;
38
+ return path.join(PROJECT_DIR, '_data', 'visualizer-layouts', ...segments, fileName);
39
+ }
40
+
41
+ /**
42
+ * Loads saved layout from disk if it exists
43
+ */
44
+ export async function loadSavedLayout(resourceKey: string): Promise<SavedLayout | null> {
45
+ const filePath = getLayoutFilePath(resourceKey);
46
+
47
+ try {
48
+ const content = await fs.promises.readFile(filePath, 'utf-8');
49
+ return JSON.parse(content) as SavedLayout;
50
+ } catch {
51
+ // File doesn't exist or parse error - return null
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Applies saved positions to nodes. Nodes with saved positions get them applied,
58
+ * nodes without saved positions keep their Dagre-calculated positions.
59
+ */
60
+ export function applyLayoutToNodes<T extends { id: string; position: { x: number; y: number } }>(
61
+ nodes: T[],
62
+ savedLayout: SavedLayout | null
63
+ ): T[];
64
+ export function applyLayoutToNodes(nodes: any[], savedLayout: SavedLayout | null): any[] {
65
+ if (!savedLayout) {
66
+ return nodes; // No saved layout, use original Dagre positions
67
+ }
68
+
69
+ return nodes.map((node) => {
70
+ const savedPosition = savedLayout.positions[node.id];
71
+ if (savedPosition) {
72
+ // Use saved position
73
+ return {
74
+ ...node,
75
+ position: { x: savedPosition.x, y: savedPosition.y },
76
+ };
77
+ }
78
+ // Keep Dagre-calculated position for new/unmatched nodes
79
+ return node;
80
+ });
81
+ }
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "url": "https://github.com/event-catalog/eventcatalog.git"
7
7
  },
8
8
  "type": "module",
9
- "version": "3.10.1",
9
+ "version": "3.11.0",
10
10
  "publishConfig": {
11
11
  "access": "public"
12
12
  },