@eventcatalog/core 4.6.3 → 4.7.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.
@@ -36,7 +36,7 @@ module.exports = __toCommonJS(analytics_exports);
36
36
  var import_os = __toESM(require("os"), 1);
37
37
 
38
38
  // package.json
39
- var version = "4.6.3";
39
+ var version = "4.7.0";
40
40
 
41
41
  // src/constants.ts
42
42
  var VERSION = version;
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  raiseEvent
3
- } from "../chunk-PWY4SP6L.js";
4
- import "../chunk-LB5VXS6X.js";
3
+ } from "../chunk-ZU5WMIH4.js";
4
+ import "../chunk-3HMY77MN.js";
5
5
  export {
6
6
  raiseEvent
7
7
  };
@@ -140,7 +140,7 @@ var verifyRequiredFieldsAreInCatalogConfigFile = async (projectDirectory) => {
140
140
  var import_os = __toESM(require("os"), 1);
141
141
 
142
142
  // package.json
143
- var version = "4.6.3";
143
+ var version = "4.7.0";
144
144
 
145
145
  // src/constants.ts
146
146
  var VERSION = version;
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  log_build_default
3
- } from "../chunk-54KFOI6Z.js";
4
- import "../chunk-PWY4SP6L.js";
3
+ } from "../chunk-WJVRBHVC.js";
5
4
  import "../chunk-K2XIENVT.js";
6
- import "../chunk-LB5VXS6X.js";
5
+ import "../chunk-ZU5WMIH4.js";
6
+ import "../chunk-3HMY77MN.js";
7
7
  import "../chunk-6QENHZZP.js";
8
8
  export {
9
9
  log_build_default as default
@@ -1,5 +1,5 @@
1
1
  // package.json
2
- var version = "4.6.3";
2
+ var version = "4.7.0";
3
3
 
4
4
  // src/constants.ts
5
5
  var VERSION = version;
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  logger
3
- } from "./chunk-FZJDCQZE.js";
3
+ } from "./chunk-HRSTURLY.js";
4
4
  import {
5
5
  cleanup,
6
6
  getEventCatalogConfigFile
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  VERSION
3
- } from "./chunk-LB5VXS6X.js";
3
+ } from "./chunk-3HMY77MN.js";
4
4
 
5
5
  // src/utils/cli-logger.ts
6
6
  import pc from "picocolors";
@@ -1,10 +1,10 @@
1
- import {
2
- raiseEvent
3
- } from "./chunk-PWY4SP6L.js";
4
1
  import {
5
2
  countResources,
6
3
  serializeCounts
7
4
  } from "./chunk-K2XIENVT.js";
5
+ import {
6
+ raiseEvent
7
+ } from "./chunk-ZU5WMIH4.js";
8
8
  import {
9
9
  getEventCatalogConfigFile,
10
10
  verifyRequiredFieldsAreInCatalogConfigFile
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  VERSION
3
- } from "./chunk-LB5VXS6X.js";
3
+ } from "./chunk-3HMY77MN.js";
4
4
 
5
5
  // src/analytics/analytics.js
6
6
  import os from "os";
@@ -25,7 +25,7 @@ __export(constants_exports, {
25
25
  module.exports = __toCommonJS(constants_exports);
26
26
 
27
27
  // package.json
28
- var version = "4.6.3";
28
+ var version = "4.7.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-LB5VXS6X.js";
3
+ } from "./chunk-3HMY77MN.js";
4
4
  export {
5
5
  VERSION
6
6
  };
@@ -144,7 +144,7 @@ var verifyRequiredFieldsAreInCatalogConfigFile = async (projectDirectory) => {
144
144
  var import_picocolors = __toESM(require("picocolors"), 1);
145
145
 
146
146
  // package.json
147
- var version = "4.6.3";
147
+ var version = "4.7.0";
148
148
 
149
149
  // src/constants.ts
150
150
  var VERSION = version;
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  log_build_default
3
- } from "./chunk-54KFOI6Z.js";
3
+ } from "./chunk-WJVRBHVC.js";
4
+ import "./chunk-K2XIENVT.js";
4
5
  import {
5
6
  FederationConflictError,
6
7
  federateCatalog
@@ -20,10 +21,10 @@ import {
20
21
  } from "./chunk-B7HCX5HM.js";
21
22
  import {
22
23
  generate
23
- } from "./chunk-4BBXOL6K.js";
24
+ } from "./chunk-CZ6C3FBV.js";
24
25
  import {
25
26
  logger
26
- } from "./chunk-FZJDCQZE.js";
27
+ } from "./chunk-HRSTURLY.js";
27
28
  import {
28
29
  resolve_catalog_dependencies_default
29
30
  } from "./chunk-LHR4G2UO.js";
@@ -33,8 +34,7 @@ import {
33
34
  import {
34
35
  watch
35
36
  } from "./chunk-CAJUIMDJ.js";
36
- import "./chunk-PWY4SP6L.js";
37
- import "./chunk-K2XIENVT.js";
37
+ import "./chunk-ZU5WMIH4.js";
38
38
  import {
39
39
  createAstroDevLineFilter,
40
40
  createAstroLineFilter
@@ -45,7 +45,7 @@ import {
45
45
  import "./chunk-W5JQON7Z.js";
46
46
  import {
47
47
  VERSION
48
- } from "./chunk-LB5VXS6X.js";
48
+ } from "./chunk-3HMY77MN.js";
49
49
  import {
50
50
  linkCoreNodeModules,
51
51
  resolveInstalledCoreNodeModules
package/dist/generate.cjs CHANGED
@@ -108,7 +108,7 @@ var getEventCatalogConfigFile = async (projectDirectory) => {
108
108
  var import_picocolors = __toESM(require("picocolors"), 1);
109
109
 
110
110
  // package.json
111
- var version = "4.6.3";
111
+ var version = "4.7.0";
112
112
 
113
113
  // src/constants.ts
114
114
  var VERSION = version;
package/dist/generate.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  generate
3
- } from "./chunk-4BBXOL6K.js";
4
- import "./chunk-FZJDCQZE.js";
5
- import "./chunk-LB5VXS6X.js";
3
+ } from "./chunk-CZ6C3FBV.js";
4
+ import "./chunk-HRSTURLY.js";
5
+ import "./chunk-3HMY77MN.js";
6
6
  import "./chunk-6QENHZZP.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 = "4.6.3";
39
+ var version = "4.7.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-FZJDCQZE.js";
4
- import "../chunk-LB5VXS6X.js";
3
+ } from "../chunk-HRSTURLY.js";
4
+ import "../chunk-3HMY77MN.js";
5
5
  export {
6
6
  logger
7
7
  };
@@ -22,6 +22,7 @@ import { buildUrl } from '@utils/url-builder';
22
22
  import { getVersionFromCollection } from '@utils/collections/versions';
23
23
  import { pageDataLoader } from '@utils/page-loaders/page-data-loader';
24
24
  import { getNodesAndEdges as getNodesAndEdgesForContainer } from '@utils/node-graphs/container-node-graph';
25
+ import { getNodesAndEdges as getNodesAndEdgesForChannel } from '@utils/node-graphs/channel-node-graph';
25
26
  import config from '@config';
26
27
  import { isEventCatalogChatEnabled, isDevMode } from '@utils/feature';
27
28
  import { loadSavedLayout, applyLayoutToNodes, buildResourceKey } from '@utils/node-graphs/layout-persistence';
@@ -79,6 +80,7 @@ const getNodesAndEdgesFunctions = {
79
80
  domains: getNodesAndEdgesForDomain,
80
81
  flows: getNodesAndEdgesForFlows,
81
82
  containers: getNodesAndEdgesForContainer,
83
+ channels: getNodesAndEdgesForChannel,
82
84
  'data-products': getNodesAndEdgesForDataProduct,
83
85
  systems: getNodesAndEdgesForSystem,
84
86
  };
@@ -13,7 +13,7 @@ import {
13
13
  removeFavorite as removeFavoriteAction,
14
14
  type FavoriteItem,
15
15
  } from '@stores/favorites-store';
16
- import { canCollapseGroup, getBadgeClasses, getGroupLabel, isGroupCollapsed } from './utils';
16
+ import { canCollapseGroup, findNodeKeyByUrl, getBadgeClasses, getGroupLabel, isGroupCollapsed } from './utils';
17
17
  import { resolveIconUrl } from '@utils/icon';
18
18
 
19
19
  const cn = (...classes: (string | false | undefined)[]) => classes.filter(Boolean).join(' ');
@@ -201,86 +201,9 @@ export default function NestedSideBar() {
201
201
  /**
202
202
  * Find a node key by matching URL patterns
203
203
  */
204
- const findNodeKeyByUrl = useCallback(
204
+ const findNodeKeyForUrl = useCallback(
205
205
  (url: string): string | null => {
206
- // Strip the base path so patterns always match against /docs/..., /visualiser/..., etc.
207
- const normalizedUrl = stripBasePath(url);
208
-
209
- // URL patterns to match resources with version
210
- const urlPatternsWithVersion = [
211
- // Domains
212
- { pattern: /^\/docs\/domains\/([^/]+)\/([^/]+)/, type: 'domain' },
213
- { pattern: /^\/visualiser\/domains\/([^/]+)\/([^/]+)/, type: 'domain' },
214
- { pattern: /^\/architecture\/domains\/([^/]+)\/([^/]+)/, type: 'domain' },
215
- // Systems (key prefix is `system`, url segment is `systems`).
216
- // The visualiser pattern also matches the System Diagram
217
- // (/visualiser/systems/:id/:version/context) since it is not end-anchored.
218
- { pattern: /^\/docs\/systems\/([^/]+)\/([^/]+)/, type: 'system' },
219
- { pattern: /^\/visualiser\/systems\/([^/]+)\/([^/]+)/, type: 'system' },
220
- { pattern: /^\/architecture\/systems\/([^/]+)\/([^/]+)/, type: 'system' },
221
- // Agents
222
- { pattern: /^\/docs\/agents\/([^/]+)\/([^/]+)/, type: 'agent' },
223
- { pattern: /^\/visualiser\/agents\/([^/]+)\/([^/]+)/, type: 'agent' },
224
- // Decision Records
225
- { pattern: /^\/docs\/adrs\/([^/]+)\/([^/]+)/, type: 'adr' },
226
- // Services
227
- { pattern: /^\/docs\/services\/([^/]+)\/([^/]+)/, type: 'service' },
228
- { pattern: /^\/architecture\/services\/([^/]+)\/([^/]+)/, type: 'service' },
229
- { pattern: /^\/visualiser\/services\/([^/]+)\/([^/]+)/, type: 'service' },
230
- // Messages (events, commands, queries) - note: keys use singular form
231
- { pattern: /^\/docs\/events\/([^/]+)\/([^/]+)/, type: 'event' },
232
- { pattern: /^\/docs\/commands\/([^/]+)\/([^/]+)/, type: 'command' },
233
- { pattern: /^\/docs\/queries\/([^/]+)\/([^/]+)/, type: 'query' },
234
- { pattern: /^\/visualiser\/messages\/([^/]+)\/([^/]+)/, type: 'message' },
235
- { pattern: /^\/visualiser\/events\/([^/]+)\/([^/]+)/, type: 'event' },
236
- { pattern: /^\/visualiser\/commands\/([^/]+)\/([^/]+)/, type: 'command' },
237
- { pattern: /^\/visualiser\/queries\/([^/]+)\/([^/]+)/, type: 'query' },
238
- // Containers
239
- { pattern: /^\/docs\/containers\/([^/]+)\/([^/]+)/, type: 'container' },
240
- { pattern: /^\/visualiser\/containers\/([^/]+)\/([^/]+)/, type: 'container' },
241
- // Flows
242
- { pattern: /^\/docs\/flows\/([^/]+)\/([^/]+)/, type: 'flow' },
243
- { pattern: /^\/visualiser\/flows\/([^/]+)\/([^/]+)/, type: 'flow' },
244
- // Data Products
245
- { pattern: /^\/docs\/data-products\/([^/]+)\/([^/]+)/, type: 'data-product' },
246
- { pattern: /^\/visualiser\/data-products\/([^/]+)\/([^/]+)/, type: 'data-product' },
247
- // Entities
248
- { pattern: /^\/docs\/entities\/([^/]+)\/([^/]+)/, type: 'entity' },
249
- ];
250
-
251
- // URL patterns without version (language pages, etc)
252
- const urlPatternsWithoutVersion = [{ pattern: /^\/docs\/domains\/([^/]+)\/language/, type: 'domain' }];
253
-
254
- // First try to match patterns with version
255
- for (const { pattern, type } of urlPatternsWithVersion) {
256
- const match = normalizedUrl.match(pattern);
257
- if (match) {
258
- const id = match[1];
259
- const version = match[2];
260
-
261
- // First try with version
262
- const keyWithVersion = `${type}:${id}:${version}`;
263
- if (nodes[keyWithVersion]) {
264
- return keyWithVersion;
265
- }
266
-
267
- // Fallback to lookup without version (for latest)
268
- const foundNodeKey = nodeLookup.get(`${type}:${id}`);
269
- if (foundNodeKey) return foundNodeKey;
270
- }
271
- }
272
-
273
- // Then try patterns without version
274
- for (const { pattern, type } of urlPatternsWithoutVersion) {
275
- const match = normalizedUrl.match(pattern);
276
- if (match) {
277
- const id = match[1];
278
- const foundNodeKey = nodeLookup.get(`${type}:${id}`);
279
- if (foundNodeKey) return foundNodeKey;
280
- }
281
- }
282
-
283
- return null;
206
+ return findNodeKeyByUrl(stripBasePath(url), nodes, nodeLookup);
284
207
  },
285
208
  [nodeLookup, nodes]
286
209
  );
@@ -337,7 +260,7 @@ export default function NestedSideBar() {
337
260
  */
338
261
  const findAndNavigateToUrl = useCallback(
339
262
  (url: string) => {
340
- const foundNodeKey = findNodeKeyByUrl(url);
263
+ const foundNodeKey = findNodeKeyForUrl(url);
341
264
 
342
265
  if (foundNodeKey) {
343
266
  setNavigationStack((currentStack) => {
@@ -373,7 +296,7 @@ export default function NestedSideBar() {
373
296
  }
374
297
  return false;
375
298
  },
376
- [findNodeKeyByUrl, tryConnectStack, resolveRef, roots]
299
+ [findNodeKeyForUrl, tryConnectStack, resolveRef, roots]
377
300
  );
378
301
 
379
302
  /**
@@ -393,7 +316,7 @@ export default function NestedSideBar() {
393
316
  }
394
317
 
395
318
  const savedState = loadState();
396
- const targetKey = findNodeKeyByUrl(currentUrl);
319
+ const targetKey = findNodeKeyForUrl(currentUrl);
397
320
 
398
321
  let finalStack: NavigationLevel[] | null = null;
399
322
 
@@ -432,7 +355,7 @@ export default function NestedSideBar() {
432
355
  }
433
356
 
434
357
  setIsInitialized(true);
435
- }, [data, roots, isInitialized, buildStackFromPath, findNodeKeyByUrl, tryConnectStack, resolveRef]);
358
+ }, [data, roots, isInitialized, buildStackFromPath, findNodeKeyForUrl, tryConnectStack, resolveRef]);
436
359
 
437
360
  /**
438
361
  * Save state whenever navigation changes
@@ -510,7 +433,7 @@ export default function NestedSideBar() {
510
433
  return stripBasePath(left).replace(/\/$/, '') === stripBasePath(right).replace(/\/$/, '');
511
434
  }, []);
512
435
 
513
- const activeNodeKey = useMemo(() => findNodeKeyByUrl(currentPath), [currentPath, findNodeKeyByUrl]);
436
+ const activeNodeKey = useMemo(() => findNodeKeyForUrl(currentPath), [currentPath, findNodeKeyForUrl]);
514
437
 
515
438
  // Show loading state if no data yet
516
439
  if (!data || roots.length === 0) {
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { canCollapseGroup, getGroupLabel, isGroupCollapsed } from './utils';
2
+ import { canCollapseGroup, findNodeKeyByUrl, getGroupLabel, isGroupCollapsed } from './utils';
3
3
 
4
4
  const preferences = (expanded: string[] = []) => ({
5
5
  expanded: new Set(expanded),
@@ -41,3 +41,21 @@ describe('isGroupCollapsed', () => {
41
41
  expect(isGroupCollapsed(true, 'outbound-messages', preferences(['outbound-messages']))).toBe(false);
42
42
  });
43
43
  });
44
+
45
+ describe('findNodeKeyByUrl', () => {
46
+ const channelKey = 'channel:product-events:1.0.0';
47
+ const nodes = { [channelKey]: {} };
48
+ const nodeLookup = new Map([['channel:product-events', channelKey]]);
49
+
50
+ it('selects the channel sidebar for channel documentation', () => {
51
+ expect(findNodeKeyByUrl('/docs/channels/product-events/1.0.0', nodes, nodeLookup)).toBe(channelKey);
52
+ });
53
+
54
+ it('keeps the channel sidebar selected on the channel map', () => {
55
+ expect(findNodeKeyByUrl('/visualiser/channels/product-events/1.0.0', nodes, nodeLookup)).toBe(channelKey);
56
+ });
57
+
58
+ it('falls back to the available channel version', () => {
59
+ expect(findNodeKeyByUrl('/docs/channels/product-events/latest', nodes, nodeLookup)).toBe(channelKey);
60
+ });
61
+ });
@@ -17,6 +17,86 @@ export const isGroupCollapsed = (canCollapse: boolean, groupId: string, preferen
17
17
  return canCollapse && !preferences.expanded.has(groupId);
18
18
  };
19
19
 
20
+ /**
21
+ * Finds the sidebar node represented by a resource URL.
22
+ * The URL must have its configured base path removed before it is passed in.
23
+ */
24
+ export const findNodeKeyByUrl = (
25
+ url: string,
26
+ nodes: Record<string, unknown>,
27
+ nodeLookup: ReadonlyMap<string, string>
28
+ ): string | null => {
29
+ const urlPatternsWithVersion = [
30
+ // Domains
31
+ { pattern: /^\/docs\/domains\/([^/]+)\/([^/]+)/, type: 'domain' },
32
+ { pattern: /^\/visualiser\/domains\/([^/]+)\/([^/]+)/, type: 'domain' },
33
+ { pattern: /^\/architecture\/domains\/([^/]+)\/([^/]+)/, type: 'domain' },
34
+ // Systems (key prefix is `system`, url segment is `systems`).
35
+ // The visualiser pattern also matches the System Diagram
36
+ // (/visualiser/systems/:id/:version/context) since it is not end-anchored.
37
+ { pattern: /^\/docs\/systems\/([^/]+)\/([^/]+)/, type: 'system' },
38
+ { pattern: /^\/visualiser\/systems\/([^/]+)\/([^/]+)/, type: 'system' },
39
+ { pattern: /^\/architecture\/systems\/([^/]+)\/([^/]+)/, type: 'system' },
40
+ // Agents
41
+ { pattern: /^\/docs\/agents\/([^/]+)\/([^/]+)/, type: 'agent' },
42
+ { pattern: /^\/visualiser\/agents\/([^/]+)\/([^/]+)/, type: 'agent' },
43
+ // Decision Records
44
+ { pattern: /^\/docs\/adrs\/([^/]+)\/([^/]+)/, type: 'adr' },
45
+ // Services
46
+ { pattern: /^\/docs\/services\/([^/]+)\/([^/]+)/, type: 'service' },
47
+ { pattern: /^\/architecture\/services\/([^/]+)\/([^/]+)/, type: 'service' },
48
+ { pattern: /^\/visualiser\/services\/([^/]+)\/([^/]+)/, type: 'service' },
49
+ // Messages (events, commands, queries) - note: keys use singular form
50
+ { pattern: /^\/docs\/events\/([^/]+)\/([^/]+)/, type: 'event' },
51
+ { pattern: /^\/docs\/commands\/([^/]+)\/([^/]+)/, type: 'command' },
52
+ { pattern: /^\/docs\/queries\/([^/]+)\/([^/]+)/, type: 'query' },
53
+ { pattern: /^\/visualiser\/messages\/([^/]+)\/([^/]+)/, type: 'message' },
54
+ { pattern: /^\/visualiser\/events\/([^/]+)\/([^/]+)/, type: 'event' },
55
+ { pattern: /^\/visualiser\/commands\/([^/]+)\/([^/]+)/, type: 'command' },
56
+ { pattern: /^\/visualiser\/queries\/([^/]+)\/([^/]+)/, type: 'query' },
57
+ // Channels
58
+ { pattern: /^\/docs\/channels\/([^/]+)\/([^/]+)/, type: 'channel' },
59
+ { pattern: /^\/visualiser\/channels\/([^/]+)\/([^/]+)/, type: 'channel' },
60
+ // Containers
61
+ { pattern: /^\/docs\/containers\/([^/]+)\/([^/]+)/, type: 'container' },
62
+ { pattern: /^\/visualiser\/containers\/([^/]+)\/([^/]+)/, type: 'container' },
63
+ // Flows
64
+ { pattern: /^\/docs\/flows\/([^/]+)\/([^/]+)/, type: 'flow' },
65
+ { pattern: /^\/visualiser\/flows\/([^/]+)\/([^/]+)/, type: 'flow' },
66
+ // Data Products
67
+ { pattern: /^\/docs\/data-products\/([^/]+)\/([^/]+)/, type: 'data-product' },
68
+ { pattern: /^\/visualiser\/data-products\/([^/]+)\/([^/]+)/, type: 'data-product' },
69
+ // Entities
70
+ { pattern: /^\/docs\/entities\/([^/]+)\/([^/]+)/, type: 'entity' },
71
+ ];
72
+
73
+ const urlPatternsWithoutVersion = [{ pattern: /^\/docs\/domains\/([^/]+)\/language/, type: 'domain' }];
74
+
75
+ for (const { pattern, type } of urlPatternsWithVersion) {
76
+ const match = url.match(pattern);
77
+ if (match) {
78
+ const id = match[1];
79
+ const version = match[2];
80
+ const keyWithVersion = `${type}:${id}:${version}`;
81
+
82
+ if (nodes[keyWithVersion]) return keyWithVersion;
83
+
84
+ const foundNodeKey = nodeLookup.get(`${type}:${id}`);
85
+ if (foundNodeKey) return foundNodeKey;
86
+ }
87
+ }
88
+
89
+ for (const { pattern, type } of urlPatternsWithoutVersion) {
90
+ const match = url.match(pattern);
91
+ if (match) {
92
+ const foundNodeKey = nodeLookup.get(`${type}:${match[1]}`);
93
+ if (foundNodeKey) return foundNodeKey;
94
+ }
95
+ }
96
+
97
+ return null;
98
+ };
99
+
20
100
  /**
21
101
  * Returns Tailwind classes for badge styling based on badge type.
22
102
  * Uses CSS variables from theme.css for proper theming support.
@@ -29,6 +29,7 @@ export class Page extends HybridPage {
29
29
  'domains',
30
30
  'flows',
31
31
  'containers',
32
+ 'channels',
32
33
  'data-products',
33
34
  'systems',
34
35
  ];
@@ -764,6 +764,8 @@ export const getNestedSideBarData = async (): Promise<NavigationData> => {
764
764
  {} as Record<string, NavNode>
765
765
  );
766
766
 
767
+ const visualiserEnabled = config?.visualiser?.enabled !== false;
768
+
767
769
  const channelNodes = channels.reduce(
768
770
  (acc, channel) => {
769
771
  const versionedKey = `channel:${channel.data.id}:${channel.data.version}`;
@@ -796,6 +798,18 @@ export const getNestedSideBarData = async (): Promise<NavigationData> => {
796
798
  ].filter(Boolean) as { title: string; href: string }[]
797
799
  ),
798
800
  docsSection,
801
+ visualiserEnabled && {
802
+ type: 'group',
803
+ title: 'Architecture',
804
+ icon: 'Workflow',
805
+ pages: [
806
+ {
807
+ type: 'item',
808
+ title: 'Map',
809
+ href: buildUrl(`/visualiser/${channel.collection}/${channel.data.id}/${channel.data.version}`),
810
+ },
811
+ ],
812
+ },
799
813
  ].filter(Boolean) as ChildRef[],
800
814
  },
801
815
  channel,
@@ -827,7 +841,6 @@ export const getNestedSideBarData = async (): Promise<NavigationData> => {
827
841
  {} as Record<string, NavNode>
828
842
  );
829
843
 
830
- const visualiserEnabled = config?.visualiser?.enabled !== false;
831
844
  const rootDomainsNodes: Record<string, NavNode> = {};
832
845
 
833
846
  if (rootDomains.length > 0) {
@@ -1,6 +1,27 @@
1
- import type { CollectionEntry } from 'astro:content';
2
- import { createEdge, createNode, generatedIdForEdge, generateIdForNode, getColorFromString } from './utils/utils';
1
+ import { getCollection, type CollectionEntry } from 'astro:content';
2
+ import dagre from 'dagre';
3
+ import {
4
+ buildContextMenuForAgent,
5
+ buildContextMenuForMessage,
6
+ buildContextMenuForResource,
7
+ buildContextMenuForService,
8
+ calculatedNodes,
9
+ createDagreGraph,
10
+ createEdge,
11
+ createNode,
12
+ generatedIdForEdge,
13
+ generateIdForNode,
14
+ getColorFromString,
15
+ getEdgeLabelForMessageAsSource,
16
+ getEdgeLabelForServiceAsTarget,
17
+ getOperationFields,
18
+ DEFAULT_NODE_WIDTH,
19
+ DEFAULT_NODE_HEIGHT,
20
+ } from './utils/utils';
21
+ import { createVersionedMap, findInMap } from '@utils/collections/util';
22
+ import { getChannelChain, getChannels } from '@utils/collections/channels';
3
23
  import { type Node, type Edge } from '@xyflow/react';
24
+ import type { CollectionMessageTypes } from '@types';
4
25
 
5
26
  interface CollectionItem {
6
27
  collection: string;
@@ -27,7 +48,15 @@ export const getNodesAndEdgesForChannelChain = ({
27
48
  createNode({
28
49
  id: generateIdForNode(channel),
29
50
  type: channel.collection,
30
- data: { mode, channel: { ...channel.data, ...channel } },
51
+ data: {
52
+ mode,
53
+ channel: { ...channel.data, ...channel },
54
+ contextMenu: buildContextMenuForResource({
55
+ collection: 'channels',
56
+ id: channel.data.id,
57
+ version: channel.data.version,
58
+ }),
59
+ },
31
60
  position: { x: 0, y: 0 },
32
61
  })
33
62
  );
@@ -73,3 +102,366 @@ export const getNodesAndEdgesForChannelChain = ({
73
102
 
74
103
  return { nodes, edges };
75
104
  };
105
+
106
+ type MessageProducerOrConsumer = CollectionEntry<'services'> | CollectionEntry<'agents'>;
107
+ type ChannelPointer = { id: string; version?: string };
108
+ type ChannelRoute = CollectionEntry<'channels'>[];
109
+ type RoutedResource = {
110
+ resource: MessageProducerOrConsumer;
111
+ channelRoutes: ChannelRoute[];
112
+ };
113
+
114
+ type DagreGraph = any;
115
+
116
+ interface Props {
117
+ id: string;
118
+ version: string;
119
+ defaultFlow?: DagreGraph;
120
+ mode?: 'simple' | 'full';
121
+ layout?: boolean;
122
+ }
123
+
124
+ /**
125
+ * Builds the graph for a single channel: everything that publishes into it on the left,
126
+ * everything that consumes out of it on the right, and any channels it routes to (or is
127
+ * routed from) either side of it.
128
+ */
129
+ export const getNodesAndEdges = async ({ id, version, defaultFlow, mode = 'simple', layout = true }: Props) => {
130
+ const flow = defaultFlow || createDagreGraph({ ranksep: 300, nodesep: 50 });
131
+ const nodes: any[] = [];
132
+ const edges: any[] = [];
133
+
134
+ const [channels, services, agents, events, commands, queries] = await Promise.all([
135
+ getChannels(),
136
+ getCollection('services'),
137
+ getCollection('agents'),
138
+ getCollection('events'),
139
+ getCollection('commands'),
140
+ getCollection('queries'),
141
+ ]);
142
+
143
+ const channel = channels.find((c) => c.data.id === id && c.data.version === version);
144
+
145
+ // Nothing found...
146
+ if (!channel) {
147
+ return { nodes: [], edges: [] };
148
+ }
149
+
150
+ const allMessages = [...events, ...commands, ...queries] as CollectionEntry<CollectionMessageTypes>[];
151
+ const messageMap = createVersionedMap(allMessages);
152
+ const channelMap = createVersionedMap(channels);
153
+
154
+ const getRoutesThroughFocusedChannel = (
155
+ pointers: ChannelPointer[] | undefined,
156
+ direction: 'producers' | 'consumers'
157
+ ): ChannelRoute[] =>
158
+ (pointers ?? []).flatMap((pointer) => {
159
+ const resolved = findInMap(channelMap, pointer.id, pointer.version) as CollectionEntry<'channels'> | undefined;
160
+ if (!resolved) return [];
161
+
162
+ const route =
163
+ direction === 'producers' ? getChannelChain(resolved, channel, channels) : getChannelChain(channel, resolved, channels);
164
+
165
+ return route.length > 0 ? [route] : [];
166
+ });
167
+
168
+ const routesDirectlyToFocusedChannel = (pointers: ChannelPointer[] | undefined) =>
169
+ (pointers ?? []).some((pointer) => {
170
+ const resolved = findInMap(channelMap, pointer.id, pointer.version) as CollectionEntry<'channels'> | undefined;
171
+ return !!resolved && generateIdForNode(resolved) === generateIdForNode(channel);
172
+ });
173
+
174
+ // messageNodeId -> { message, producers, consumers }
175
+ const messageFlows = new Map<
176
+ string,
177
+ {
178
+ message: CollectionEntry<CollectionMessageTypes>;
179
+ producers: RoutedResource[];
180
+ consumers: RoutedResource[];
181
+ }
182
+ >();
183
+
184
+ const trackFlow = (
185
+ message: CollectionEntry<CollectionMessageTypes>,
186
+ resource: MessageProducerOrConsumer,
187
+ direction: 'producers' | 'consumers',
188
+ channelRoutes: ChannelRoute[]
189
+ ) => {
190
+ const messageNodeId = generateIdForNode(message);
191
+ const flowForMessage = messageFlows.get(messageNodeId) ?? { message, producers: [], consumers: [] };
192
+ const alreadyTracked = flowForMessage[direction].some(
193
+ (existing) => existing.resource.data.id === resource.data.id && existing.resource.data.version === resource.data.version
194
+ );
195
+
196
+ if (!alreadyTracked) {
197
+ flowForMessage[direction].push({ resource, channelRoutes });
198
+ }
199
+
200
+ messageFlows.set(messageNodeId, flowForMessage);
201
+ };
202
+
203
+ for (const resource of [...services, ...agents] as MessageProducerOrConsumer[]) {
204
+ const pointerGroups = [
205
+ { pointers: resource.data.sends ?? [], channelKey: 'to' as const, direction: 'producers' as const },
206
+ { pointers: resource.data.receives ?? [], channelKey: 'from' as const, direction: 'consumers' as const },
207
+ ];
208
+
209
+ for (const { pointers, channelKey, direction } of pointerGroups) {
210
+ for (const pointer of pointers as any[]) {
211
+ const message = findInMap(messageMap, pointer.id, pointer.version) as CollectionEntry<CollectionMessageTypes> | undefined;
212
+
213
+ if (!message) continue;
214
+
215
+ const channelPointers = pointer[channelKey] as ChannelPointer[] | undefined;
216
+ const hasExplicitChannels = Array.isArray(channelPointers) && channelPointers.length > 0;
217
+ const effectiveChannelPointers = hasExplicitChannels
218
+ ? channelPointers
219
+ : (message.data.channels as ChannelPointer[] | undefined);
220
+ const channelRoutes = getRoutesThroughFocusedChannel(effectiveChannelPointers, direction);
221
+
222
+ if (channelRoutes.length > 0) {
223
+ trackFlow(message, resource, direction, channelRoutes);
224
+ }
225
+ }
226
+ }
227
+ }
228
+
229
+ // Messages whose declared channel routes into the focused channel still belong on the graph,
230
+ // even when no producer or consumer references them.
231
+ for (const message of allMessages) {
232
+ const messageNodeId = generateIdForNode(message);
233
+ const declaredRoutes = getRoutesThroughFocusedChannel(message.data.channels as ChannelPointer[] | undefined, 'producers');
234
+ if (declaredRoutes.length > 0 && !messageFlows.has(messageNodeId)) {
235
+ messageFlows.set(messageNodeId, { message, producers: [], consumers: [] });
236
+ }
237
+ }
238
+
239
+ const channelNodeId = generateIdForNode(channel);
240
+ const addedNodeIds = new Set<string>();
241
+ const addedEdgeIds = new Set<string>();
242
+
243
+ const addNode = (node: any) => {
244
+ if (addedNodeIds.has(node.id)) return;
245
+ addedNodeIds.add(node.id);
246
+ nodes.push(node);
247
+ };
248
+
249
+ const addEdge = (edge: any) => {
250
+ if (addedEdgeIds.has(edge.id)) return;
251
+ addedEdgeIds.add(edge.id);
252
+ edges.push(edge);
253
+ };
254
+
255
+ const addResourceNode = (resource: MessageProducerOrConsumer) => {
256
+ addNode(
257
+ createNode({
258
+ id: generateIdForNode(resource),
259
+ type: resource.collection,
260
+ data: {
261
+ mode,
262
+ ...(resource.collection === 'agents' ? { agent: { ...resource.data } } : { service: { ...resource.data } }),
263
+ contextMenu:
264
+ resource.collection === 'agents'
265
+ ? buildContextMenuForAgent({ id: resource.data.id, version: resource.data.version })
266
+ : buildContextMenuForService({
267
+ id: resource.data.id,
268
+ version: resource.data.version,
269
+ specifications: (resource.data as any).specifications,
270
+ repository: (resource.data as any).repository,
271
+ }),
272
+ },
273
+ position: { x: 0, y: 0 },
274
+ })
275
+ );
276
+ };
277
+
278
+ const addChannelNode = (channelToAdd: CollectionEntry<'channels'>, isFocused = false) => {
279
+ addNode(
280
+ createNode({
281
+ id: generateIdForNode(channelToAdd),
282
+ type: channelToAdd.collection,
283
+ data: {
284
+ mode,
285
+ ...(isFocused ? { isFocused: true } : {}),
286
+ channel: { ...channelToAdd.data },
287
+ contextMenu: buildContextMenuForResource({
288
+ collection: 'channels',
289
+ id: channelToAdd.data.id,
290
+ version: channelToAdd.data.version,
291
+ }),
292
+ },
293
+ position: { x: 0, y: 0 },
294
+ })
295
+ );
296
+ };
297
+
298
+ // The channel itself
299
+ addChannelNode(channel, true);
300
+
301
+ const addChannelRoute = (route: ChannelRoute, colorSourceId: string) => {
302
+ route.forEach((channelInRoute) => addChannelNode(channelInRoute, generateIdForNode(channelInRoute) === channelNodeId));
303
+
304
+ for (let index = 0; index < route.length - 1; index++) {
305
+ const sourceChannel = route[index];
306
+ const targetChannel = route[index + 1];
307
+ addEdge(
308
+ createEdge({
309
+ id: generatedIdForEdge(sourceChannel, targetChannel),
310
+ source: generateIdForNode(sourceChannel),
311
+ target: generateIdForNode(targetChannel),
312
+ label: 'routes to',
313
+ data: { customColor: getColorFromString(colorSourceId) },
314
+ })
315
+ );
316
+ }
317
+ };
318
+
319
+ // channelNodeId + consumerNodeId -> messages consumed, so each channel/consumer pair gets one edge.
320
+ const messagesByConsumer = new Map<
321
+ string,
322
+ {
323
+ sourceChannel: CollectionEntry<'channels'>;
324
+ consumer: MessageProducerOrConsumer;
325
+ messages: CollectionEntry<CollectionMessageTypes>[];
326
+ }
327
+ >();
328
+
329
+ for (const { message, producers, consumers } of messageFlows.values()) {
330
+ const messageNodeId = generateIdForNode(message);
331
+
332
+ addNode(
333
+ createNode({
334
+ id: messageNodeId,
335
+ type: message.collection,
336
+ data: {
337
+ mode,
338
+ message: { ...message.data, ...getOperationFields(message.data) },
339
+ contextMenu: buildContextMenuForMessage({
340
+ id: message.data.id,
341
+ version: message.data.version,
342
+ name: message.data.name,
343
+ collection: message.collection,
344
+ schemaPath: (message.data as any).schemaPath,
345
+ }),
346
+ },
347
+ position: { x: 0, y: 0 },
348
+ })
349
+ );
350
+
351
+ const producerRoutes = producers.flatMap((producer) => producer.channelRoutes);
352
+ const declaredRoutes = getRoutesThroughFocusedChannel(message.data.channels as ChannelPointer[] | undefined, 'producers');
353
+ const ingressRoutes = producerRoutes.length > 0 ? producerRoutes : declaredRoutes;
354
+
355
+ // When only a consumer declares the focused channel, the message still enters at the
356
+ // focused channel, matching the message graph's no-producer-channel behaviour.
357
+ const routesToRender = ingressRoutes.length > 0 ? ingressRoutes : [[channel]];
358
+ for (const route of routesToRender) {
359
+ addChannelRoute(route, message.data.id);
360
+ const firstChannel = route[0];
361
+ addEdge(
362
+ createEdge({
363
+ id: generatedIdForEdge(message, firstChannel),
364
+ source: messageNodeId,
365
+ target: generateIdForNode(firstChannel),
366
+ label: 'routes to',
367
+ data: { customColor: getColorFromString(message.data.id) },
368
+ })
369
+ );
370
+ }
371
+
372
+ for (const { resource: producer } of producers) {
373
+ addResourceNode(producer);
374
+ addEdge(
375
+ createEdge({
376
+ id: generatedIdForEdge(producer, message),
377
+ source: generateIdForNode(producer),
378
+ target: messageNodeId,
379
+ label: getEdgeLabelForServiceAsTarget(message),
380
+ type: 'multiline',
381
+ data: { customColor: getColorFromString(message.data.id) },
382
+ })
383
+ );
384
+ }
385
+
386
+ for (const { resource: consumer, channelRoutes } of consumers) {
387
+ for (const route of channelRoutes) {
388
+ addChannelRoute(route, message.data.id);
389
+ const sourceChannel = route[route.length - 1];
390
+ const consumerNodeId = generateIdForNode(consumer);
391
+ const groupKey = `${generateIdForNode(sourceChannel)}:${consumerNodeId}`;
392
+ const grouped = messagesByConsumer.get(groupKey) ?? { sourceChannel, consumer, messages: [] };
393
+ if (!grouped.messages.some((groupedMessage) => generateIdForNode(groupedMessage) === messageNodeId)) {
394
+ grouped.messages.push(message);
395
+ }
396
+ messagesByConsumer.set(groupKey, grouped);
397
+ }
398
+ }
399
+ }
400
+
401
+ // One edge per consumer, labelled with the message when there is only one
402
+ for (const { sourceChannel, consumer, messages } of messagesByConsumer.values()) {
403
+ addResourceNode(consumer);
404
+ addEdge(
405
+ createEdge({
406
+ id: generatedIdForEdge(sourceChannel, consumer),
407
+ source: generateIdForNode(sourceChannel),
408
+ target: generateIdForNode(consumer),
409
+ label:
410
+ messages.length === 1 ? getEdgeLabelForMessageAsSource(messages[0], true) : `consumes ${messages.length}\nmessages`,
411
+ type: 'multiline',
412
+ data: { customColor: getColorFromString(sourceChannel.data.id) },
413
+ })
414
+ );
415
+ }
416
+
417
+ // Channels this channel routes messages on to
418
+ for (const route of (channel.data.routes ?? []) as { id: string; version?: string }[]) {
419
+ const routedChannel = findInMap(channelMap, route.id, route.version) as CollectionEntry<'channels'> | undefined;
420
+ if (!routedChannel || generateIdForNode(routedChannel) === channelNodeId) continue;
421
+
422
+ addChannelNode(routedChannel);
423
+ addEdge(
424
+ createEdge({
425
+ id: generatedIdForEdge(channel, routedChannel),
426
+ source: channelNodeId,
427
+ target: generateIdForNode(routedChannel),
428
+ label: 'routes to',
429
+ data: { customColor: getColorFromString(channel.data.id) },
430
+ })
431
+ );
432
+ }
433
+
434
+ // Channels that route messages into this channel
435
+ for (const upstreamChannel of channels) {
436
+ if (generateIdForNode(upstreamChannel) === channelNodeId) continue;
437
+ if (!routesDirectlyToFocusedChannel(upstreamChannel.data.routes as ChannelPointer[] | undefined)) continue;
438
+
439
+ addChannelNode(upstreamChannel);
440
+ addEdge(
441
+ createEdge({
442
+ id: generatedIdForEdge(upstreamChannel, channel),
443
+ source: generateIdForNode(upstreamChannel),
444
+ target: channelNodeId,
445
+ label: 'routes to',
446
+ data: { customColor: getColorFromString(upstreamChannel.data.id) },
447
+ })
448
+ );
449
+ }
450
+
451
+ nodes.forEach((node) => {
452
+ flow.setNode(node.id, { width: DEFAULT_NODE_WIDTH, height: DEFAULT_NODE_HEIGHT });
453
+ });
454
+
455
+ edges.forEach((edge) => {
456
+ flow.setEdge(edge.source, edge.target);
457
+ });
458
+
459
+ if (layout) {
460
+ dagre.layout(flow);
461
+ }
462
+
463
+ return {
464
+ nodes: calculatedNodes(flow, nodes),
465
+ edges,
466
+ };
467
+ };
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "license": "SEE LICENSE IN LICENSE",
9
9
  "type": "module",
10
- "version": "4.6.3",
10
+ "version": "4.7.0",
11
11
  "publishConfig": {
12
12
  "access": "public"
13
13
  },
@@ -121,7 +121,7 @@
121
121
  "zod": "^4.3.6",
122
122
  "@eventcatalog/linter": "1.1.11",
123
123
  "@eventcatalog/sdk": "2.27.2",
124
- "@eventcatalog/visualiser": "^4.1.2"
124
+ "@eventcatalog/visualiser": "^4.1.3"
125
125
  },
126
126
  "devDependencies": {
127
127
  "@astrojs/check": "^0.9.10",