@eventcatalog/core 3.48.4 → 4.0.0-beta.1

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 (109) 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/catalog-to-astro-content-directory.cjs +1 -1
  6. package/dist/chunk-BWN544LB.js +73 -0
  7. package/dist/{chunk-XNVSSL4W.js → chunk-JTZT2SDF.js} +1 -1
  8. package/dist/{chunk-JY4H7A4S.js → chunk-M2ZRT7KQ.js} +1 -1
  9. package/dist/{chunk-SY4XL6OQ.js → chunk-MBVTS76Q.js} +1 -1
  10. package/dist/{chunk-KDLSBQRP.js → chunk-TZTPGQGL.js} +1 -1
  11. package/dist/{chunk-FNDSHOV2.js → chunk-U5OTNDVQ.js} +1 -1
  12. package/dist/constants.cjs +1 -1
  13. package/dist/constants.js +1 -1
  14. package/dist/core-node-modules.cjs +108 -0
  15. package/dist/core-node-modules.d.cts +7 -0
  16. package/dist/core-node-modules.d.ts +7 -0
  17. package/dist/core-node-modules.js +8 -0
  18. package/dist/docs/development/components/components/15-prompt.md +5 -1
  19. package/dist/eventcatalog.cjs +124 -48
  20. package/dist/eventcatalog.js +26 -15
  21. package/dist/generate.cjs +1 -1
  22. package/dist/generate.js +3 -3
  23. package/dist/utils/cli-logger.cjs +1 -1
  24. package/dist/utils/cli-logger.js +2 -2
  25. package/eventcatalog/astro.config.mjs +27 -23
  26. package/eventcatalog/public/icons/languages/java.svg +1 -0
  27. package/eventcatalog/public/icons/payments/stripe.svg +1 -0
  28. package/eventcatalog/src/components/Badge.astro +10 -10
  29. package/eventcatalog/src/components/CopyAsMarkdown.tsx +154 -0
  30. package/eventcatalog/src/components/Grids/DomainGrid.tsx +207 -44
  31. package/eventcatalog/src/components/Grids/MessageGrid.tsx +0 -20
  32. package/eventcatalog/src/components/Grids/SystemGrid.tsx +185 -0
  33. package/eventcatalog/src/components/MDX/ADRTable/ADRTable.astro +99 -0
  34. package/eventcatalog/src/components/MDX/ADRTable/ADRTable.client.tsx +400 -0
  35. package/eventcatalog/src/components/MDX/ContextDiagram/ContextDiagramPortal.tsx +21 -0
  36. package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +1 -0
  37. package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +42 -6
  38. package/eventcatalog/src/components/MDX/Prompt/Prompt.astro +62 -9
  39. package/eventcatalog/src/components/MDX/ResourceRef/ResourceRef.astro +8 -1
  40. package/eventcatalog/src/components/MDX/SystemContextMap/SystemContextMapPortal.tsx +22 -0
  41. package/eventcatalog/src/components/MDX/components.tsx +7 -0
  42. package/eventcatalog/src/components/SchemaExplorer/SchemaExplorer.tsx +1 -1
  43. package/eventcatalog/src/components/SideNav/NestedSideBar/index.tsx +67 -37
  44. package/eventcatalog/src/components/Tables/Discover/DiscoverTable.tsx +47 -0
  45. package/eventcatalog/src/components/Tables/Discover/FilterComponents.tsx +10 -2
  46. package/eventcatalog/src/components/Tables/Discover/columns.tsx +115 -27
  47. package/eventcatalog/src/components/Tables/Discover/index.ts +7 -1
  48. package/eventcatalog/src/components/Tables/SystemResources/SystemResourcesTable.tsx +267 -0
  49. package/eventcatalog/src/content.config.ts +96 -8
  50. package/eventcatalog/src/enterprise/collections/resource-docs-utils.ts +4 -1
  51. package/eventcatalog/src/enterprise/custom-documentation/pages/docs/custom/index.astro +12 -13
  52. package/eventcatalog/src/enterprise/custom-documentation/utils/custom-docs.ts +6 -0
  53. package/eventcatalog/src/enterprise/feature.ts +3 -1
  54. package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +96 -140
  55. package/eventcatalog/src/layouts/VisualiserLayout.astro +66 -0
  56. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.ts +4 -2
  57. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/index.astro +2 -0
  58. package/eventcatalog/src/pages/diagrams/[id]/[version]/index.astro +15 -15
  59. package/eventcatalog/src/pages/discover/[type]/_index.data.ts +1 -0
  60. package/eventcatalog/src/pages/discover/[type]/index.astro +47 -2
  61. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/[docType]/[docId]/[docVersion]/index.astro +15 -14
  62. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/[docType]/[docId]/index.astro +13 -14
  63. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/_index.data.ts +1 -0
  64. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/changelog/_index.data.ts +11 -1
  65. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/index.astro +81 -26
  66. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_index.data.ts +89 -0
  67. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/index.astro +85 -0
  68. package/eventcatalog/src/pages/docs/[type]/[id]/[version].md.ts +2 -0
  69. package/eventcatalog/src/pages/docs/[type]/[id]/[version].mdx.ts +2 -0
  70. package/eventcatalog/src/pages/docs/[type]/[id]/_index.data.ts +1 -0
  71. package/eventcatalog/src/pages/docs/teams/[id]/index.astro +24 -1
  72. package/eventcatalog/src/pages/docs/users/[id]/index.astro +24 -1
  73. package/eventcatalog/src/pages/index.astro +30 -7
  74. package/eventcatalog/src/pages/visualiser/[type]/[id]/[version]/_index.data.ts +1 -0
  75. package/eventcatalog/src/pages/visualiser/[type]/[id]/[version]/index.astro +1 -1
  76. package/eventcatalog/src/pages/visualiser/[type]/[id]/[version].mermaid.ts +3 -0
  77. package/eventcatalog/src/pages/visualiser/domains/[id]/[version]/systems-context/_index.data.ts +94 -0
  78. package/eventcatalog/src/pages/visualiser/domains/[id]/[version]/systems-context/index.astro +51 -0
  79. package/eventcatalog/src/pages/visualiser/system-context-map/index.astro +41 -0
  80. package/eventcatalog/src/pages/visualiser/systems/[id]/[version]/context/_index.data.ts +91 -0
  81. package/eventcatalog/src/pages/visualiser/systems/[id]/[version]/context/index.astro +51 -0
  82. package/eventcatalog/src/stores/sidebar-store/builders/adr.ts +3 -1
  83. package/eventcatalog/src/stores/sidebar-store/builders/agent.ts +3 -1
  84. package/eventcatalog/src/stores/sidebar-store/builders/container.ts +1 -1
  85. package/eventcatalog/src/stores/sidebar-store/builders/data-product.ts +1 -1
  86. package/eventcatalog/src/stores/sidebar-store/builders/domain.ts +127 -49
  87. package/eventcatalog/src/stores/sidebar-store/builders/message.ts +1 -1
  88. package/eventcatalog/src/stores/sidebar-store/builders/service.ts +4 -2
  89. package/eventcatalog/src/stores/sidebar-store/builders/shared.ts +2 -1
  90. package/eventcatalog/src/stores/sidebar-store/builders/system.ts +166 -0
  91. package/eventcatalog/src/stores/sidebar-store/state.ts +99 -6
  92. package/eventcatalog/src/types/index.ts +2 -0
  93. package/eventcatalog/src/utils/collection-colors.ts +6 -1
  94. package/eventcatalog/src/utils/collections/domains.ts +35 -0
  95. package/eventcatalog/src/utils/collections/icons.ts +4 -5
  96. package/eventcatalog/src/utils/collections/systems.ts +110 -0
  97. package/eventcatalog/src/utils/collections/teams.ts +15 -10
  98. package/eventcatalog/src/utils/collections/users.ts +7 -4
  99. package/eventcatalog/src/utils/collections/util.ts +2 -0
  100. package/eventcatalog/src/utils/icon-map.ts +20 -0
  101. package/eventcatalog/src/utils/icon.ts +11 -0
  102. package/eventcatalog/src/utils/node-graphs/container-node-graph.ts +12 -2
  103. package/eventcatalog/src/utils/node-graphs/domains-node-graph.ts +0 -170
  104. package/eventcatalog/src/utils/node-graphs/system-context-node-graph.ts +400 -0
  105. package/eventcatalog/src/utils/node-graphs/systems-node-graph.ts +208 -0
  106. package/eventcatalog/src/utils/node-graphs/utils/utils.ts +12 -0
  107. package/eventcatalog/src/utils/page-loaders/page-data-loader.ts +2 -0
  108. package/package.json +17 -14
  109. package/eventcatalog/src/pages/visualiser/context-map/index.astro +0 -30
@@ -17,3 +17,14 @@ export function iconFieldsForResource(
17
17
  const styleIcon = data?.styles?.icon;
18
18
  return isIconPath(styleIcon) ? { leftIcon: styleIcon } : { icon: defaultIcon };
19
19
  }
20
+
21
+ /**
22
+ * Like {@link iconFieldsForResource} but with no default icon: returns the custom
23
+ * `styles.icon` when one is defined, otherwise no icon at all. Used where the
24
+ * surrounding context (a typed section header) already conveys the resource type,
25
+ * so the default per-item icon would be redundant.
26
+ */
27
+ export function customIconFieldsForResource(data: { styles?: { icon?: string } } | undefined): { leftIcon: string } | {} {
28
+ const styleIcon = data?.styles?.icon;
29
+ return isIconPath(styleIcon) ? { leftIcon: styleIcon } : {};
30
+ }
@@ -24,9 +24,17 @@ interface Props {
24
24
  mode?: 'simple' | 'full';
25
25
  channelRenderMode?: 'flat' | 'single';
26
26
  collection?: CollectionEntry<'containers'>[];
27
+ layout?: boolean;
27
28
  }
28
29
 
29
- export const getNodesAndEdges = async ({ id, version, defaultFlow, mode = 'simple', channelRenderMode = 'flat' }: Props) => {
30
+ export const getNodesAndEdges = async ({
31
+ id,
32
+ version,
33
+ defaultFlow,
34
+ mode = 'simple',
35
+ channelRenderMode = 'flat',
36
+ layout = true,
37
+ }: Props) => {
30
38
  // 1. Fetch data
31
39
  const containers = await getContainers();
32
40
 
@@ -289,7 +297,9 @@ export const getNodesAndEdges = async ({ id, version, defaultFlow, mode = 'simpl
289
297
  });
290
298
 
291
299
  // Render the diagram in memory getting hte X and Y
292
- dagre.layout(flow);
300
+ if (layout) {
301
+ dagre.layout(flow);
302
+ }
293
303
 
294
304
  return {
295
305
  nodes: calculatedNodes(flow, nodes),
@@ -13,181 +13,11 @@ import { getNodesAndEdges as getAgentsNodeAndEdges } from './agents-node-graph';
13
13
  import { getNodesAndEdges as getDataProductsNodeAndEdges } from './data-products-node-graph';
14
14
  import merge from 'lodash.merge';
15
15
  import { createVersionedMap, findInMap } from '@utils/collections/util';
16
- import type { Node } from '@xyflow/react';
17
16
  import { getProducersOfMessage } from '@utils/collections/services';
18
17
  import { getProducersOfMessage as getAgentProducersOfMessage } from '@utils/collections/agents';
19
18
 
20
19
  type DagreGraph = any;
21
20
 
22
- interface Props {
23
- defaultFlow?: DagreGraph;
24
- channelRenderMode?: 'single' | 'flat';
25
- }
26
-
27
- export const getNodesAndEdgesForDomainContextMap = async ({ defaultFlow = null }: Props) => {
28
- const flow = defaultFlow ?? createDagreGraph({ ranksep: 360, nodesep: 50, edgesep: 50 });
29
- let nodes = [] as any,
30
- edges = [] as any;
31
-
32
- // 1. Parallel Fetching
33
- const [allDomains, services, agents, events, commands, queries] = await Promise.all([
34
- getCollection('domains'),
35
- getCollection('services'),
36
- getCollection('agents'),
37
- getCollection('events'),
38
- getCollection('commands'),
39
- getCollection('queries'),
40
- ]);
41
-
42
- const domains = allDomains.filter((domain) => !domain.id.includes('/versioned'));
43
- const messages = [...events, ...commands, ...queries];
44
-
45
- // 2. Build optimized maps
46
- const serviceMap = createVersionedMap(services);
47
- const agentMap = createVersionedMap(agents);
48
- const messageMap = createVersionedMap(messages);
49
-
50
- domains.forEach((domain, index) => {
51
- const nodeId = generateIdForNode(domain);
52
- const rawServices = domain.data.services ?? [];
53
- const rawAgents = domain.data.agents ?? [];
54
-
55
- // Optimized service resolution
56
- const domainServices = rawServices
57
- .map((service) => findInMap(serviceMap, service.id, service.version))
58
- .filter((e) => e !== undefined);
59
- const domainAgents = rawAgents.map((agent) => findInMap(agentMap, agent.id, agent.version)).filter((e) => e !== undefined);
60
- const domainResources = [...domainServices, ...domainAgents];
61
-
62
- // Calculate domain node size based on services
63
- const servicesCount = domainResources.length;
64
- const SERVICES_PER_ROW = 1;
65
- const SERVICE_WIDTH = 330;
66
- const SERVICE_HEIGHT = 120;
67
- const PADDING = 40;
68
- const TITLE_HEIGHT = 20;
69
-
70
- const rows = Math.ceil(servicesCount / SERVICES_PER_ROW);
71
- const domainWidth = SERVICE_WIDTH * SERVICES_PER_ROW;
72
- const domainHeight = SERVICE_HEIGHT * rows + PADDING * 4;
73
-
74
- // Position domains in a grid layout
75
- const DOMAINS_PER_ROW = 2;
76
- const rowIndex = Math.floor(index / DOMAINS_PER_ROW);
77
- const colIndex = index % DOMAINS_PER_ROW;
78
-
79
- nodes.push({
80
- id: nodeId,
81
- type: 'group',
82
- position: {
83
- x: colIndex * (domainWidth + 400),
84
- y: rowIndex * (domainHeight + 300),
85
- },
86
- style: {
87
- width: domainWidth,
88
- height: domainHeight,
89
- backgroundColor: 'transparent',
90
- borderRadius: '8px',
91
- border: '1px solid #ddd',
92
- 'box-shadow': '0 0 10px 0 rgba(0, 0, 0, 0.1)',
93
- },
94
- data: {
95
- label: domain.data.name,
96
- domain,
97
- },
98
- });
99
-
100
- nodes.push({
101
- id: `domain-context-map-title-${domain.data.name}`,
102
- data: { label: `Bounded Context: ${domain.data.name}` },
103
- position: { x: 0, y: 0 },
104
- style: {
105
- height: 20,
106
- backgroundColor: 'transparent',
107
- border: 'none',
108
- color: 'black',
109
- width: domainWidth,
110
- },
111
- extent: 'parent',
112
- parentId: nodeId,
113
- connectable: false,
114
- sourcePosition: 'left',
115
- targetPosition: 'right',
116
- draggable: false,
117
- } as Node);
118
-
119
- // Position services in a grid within the domain
120
- if (domainResources) {
121
- domainResources.forEach((service, serviceIndex) => {
122
- const row = Math.floor(serviceIndex / SERVICES_PER_ROW);
123
- const col = serviceIndex % SERVICES_PER_ROW;
124
-
125
- // Add spacing between services
126
- const SERVICE_MARGIN = 25;
127
- const xPosition = PADDING + col * (SERVICE_WIDTH + SERVICE_MARGIN) + 20;
128
- const yPosition = PADDING + row * (SERVICE_HEIGHT + SERVICE_MARGIN) + TITLE_HEIGHT;
129
-
130
- nodes.push({
131
- id: generateIdForNode(service),
132
- sourcePosition: 'right',
133
- targetPosition: 'left',
134
- type: service.collection,
135
- position: {
136
- x: xPosition,
137
- y: yPosition,
138
- },
139
- parentId: nodeId,
140
- extent: 'parent',
141
- draggable: false,
142
- data: {
143
- mode: 'full',
144
- ...(service.collection === 'agents' ? { agent: service.data } : { service: service.data }),
145
- },
146
- });
147
-
148
- // Edges
149
- const rawReceives = service.data.receives ?? [];
150
- const rawSends = service.data.sends ?? [];
151
-
152
- // Optimized message resolution
153
- const receives = rawReceives
154
- .map((receive) => findInMap(messageMap, receive.id, receive.version))
155
- .filter((msg): msg is any => !!msg); // Filter undefined
156
-
157
- // Note: 'sends' was defined but not used in the original loop logic for edges?
158
- // Based on original code, it iterates `receives`.
159
-
160
- for (const receive of receives) {
161
- const producers = [...getAgentProducersOfMessage(agents, receive), ...getProducersOfMessage(services, receive)];
162
-
163
- for (const producer of producers) {
164
- const isSameDomain = domainResources.some(
165
- (domainService) => domainService.collection === producer.collection && domainService.data.id === producer.data.id
166
- );
167
-
168
- if (!isSameDomain) {
169
- edges.push(
170
- createEdge({
171
- id: generatedIdForEdge(producer, service),
172
- source: generateIdForNode(producer),
173
- target: generateIdForNode(service),
174
- label: getEdgeLabelForServiceAsTarget(receive),
175
- zIndex: 1000,
176
- })
177
- );
178
- }
179
- }
180
- }
181
- });
182
- }
183
- });
184
-
185
- return {
186
- nodes,
187
- edges,
188
- };
189
- };
190
-
191
21
  interface NodesAndEdgesProps {
192
22
  id: string;
193
23
  version: string;
@@ -0,0 +1,400 @@
1
+ import { getCollection } from 'astro:content';
2
+ import type { CollectionEntry } from 'astro:content';
3
+ import dagre from 'dagre';
4
+ import {
5
+ createDagreGraph,
6
+ calculatedNodes,
7
+ createEdge,
8
+ generateIdForNode,
9
+ buildContextMenuForSystem,
10
+ DEFAULT_NODE_HEIGHT,
11
+ } from '@utils/node-graphs/utils/utils';
12
+ import { MarkerType } from '@xyflow/react';
13
+ import { createVersionedMap, findInMap } from '@utils/collections/util';
14
+
15
+ type DagreGraph = any;
16
+
17
+ interface NodesAndEdgesProps {
18
+ id: string;
19
+ version: string;
20
+ defaultFlow?: DagreGraph;
21
+ mode?: 'simple' | 'full';
22
+ layout?: boolean;
23
+ }
24
+
25
+ // System nodes are wider than the default to fit the system name + summary comfortably.
26
+ const SYSTEM_NODE_WIDTH = 270;
27
+ // Actor nodes are narrower than systems — a compact pill with an icon + name.
28
+ const ACTOR_NODE_WIDTH = 180;
29
+
30
+ type SystemActor = {
31
+ id: string;
32
+ name?: string;
33
+ label?: string;
34
+ direction?: 'inbound' | 'outbound';
35
+ };
36
+
37
+ type SystemRelationship = {
38
+ id: string;
39
+ version?: string;
40
+ label?: string;
41
+ };
42
+
43
+ type IncomingSystemRelationship = {
44
+ source: CollectionEntry<'systems'>;
45
+ relationship: SystemRelationship;
46
+ };
47
+
48
+ interface ContextGraphFromSeedsProps {
49
+ // The systems the breadth-first traversal starts from. The single-system context
50
+ // diagram seeds with one system; the domain-level diagram seeds with every system
51
+ // in the domain.
52
+ seedSystems: CollectionEntry<'systems'>[];
53
+ systemMap: Map<string, CollectionEntry<'systems'>[]>;
54
+ serviceMap: Map<string, CollectionEntry<'services'>[]>;
55
+ defaultFlow?: DagreGraph;
56
+ mode?: 'simple' | 'full';
57
+ layout?: boolean;
58
+ }
59
+
60
+ /**
61
+ * Shared builder for the System Diagram.
62
+ *
63
+ * Starting from the given seed systems, we walk system relationships in both directions
64
+ * (breadth-first) to the systems it relates to and the systems that relate to it, then
65
+ * their relationships, and so on — building the reachable neighbourhood. Each system
66
+ * becomes a node; each relationship that has a `label` becomes a labelled edge
67
+ * (relationships without a label are intentionally not drawn). Actors are rendered
68
+ * inline and deduped across all systems.
69
+ */
70
+ const buildContextGraphFromSeeds = ({
71
+ seedSystems,
72
+ systemMap,
73
+ serviceMap,
74
+ defaultFlow,
75
+ mode = 'simple',
76
+ layout = true,
77
+ }: ContextGraphFromSeedsProps) => {
78
+ const flow = defaultFlow || createDagreGraph({ ranksep: 280, nodesep: 80, edgesep: 50 });
79
+ const nodes = new Map<string, any>();
80
+ const edges = new Map<string, any>();
81
+
82
+ // Total messages a system handles = the sum of sends + receives across all of
83
+ // its services. Used for the "Messages" stat on the system node.
84
+ const countMessagesForSystem = (system: CollectionEntry<'systems'>) => {
85
+ return (system.data.services || []).reduce((total, ref) => {
86
+ const service = findInMap(serviceMap, ref.id, ref.version);
87
+ if (!service) return total;
88
+ return total + (service.data.sends || []).length + (service.data.receives || []).length;
89
+ }, 0);
90
+ };
91
+
92
+ // Actor node ids, tracked so they can be sized differently during layout.
93
+ const actorNodeIds = new Set<string>();
94
+ // System node ids, tracked so we can detect reciprocal system-to-system
95
+ // relationships and collapse them into a single double-headed edge.
96
+ const systemNodeIds = new Set<string>();
97
+ const incomingRelationshipsByTargetNodeId = new Map<string, IncomingSystemRelationship[]>();
98
+
99
+ for (const versionedSystems of systemMap.values()) {
100
+ for (const source of versionedSystems) {
101
+ const relationships = (source.data.relationships || []) as SystemRelationship[];
102
+
103
+ for (const relationship of relationships) {
104
+ const target = findInMap(systemMap, relationship.id, relationship.version);
105
+ if (!target) continue;
106
+
107
+ const targetNodeId = generateIdForNode(target);
108
+ const incoming = incomingRelationshipsByTargetNodeId.get(targetNodeId) || [];
109
+ incoming.push({ source, relationship });
110
+ incomingRelationshipsByTargetNodeId.set(targetNodeId, incoming);
111
+ }
112
+ }
113
+ }
114
+
115
+ // Add (or reuse) an actor node. Actors are inline and deduped by `actor:{id}`,
116
+ // so the same actor referenced from multiple systems appears once.
117
+ const addActorNode = (actor: SystemActor) => {
118
+ const nodeId = `actor-${actor.id}`;
119
+ if (nodes.has(nodeId)) return nodeId;
120
+
121
+ actorNodeIds.add(nodeId);
122
+ nodes.set(nodeId, {
123
+ id: nodeId,
124
+ type: 'context-actor',
125
+ sourcePosition: 'right',
126
+ targetPosition: 'left',
127
+ data: {
128
+ mode,
129
+ name: actor.name || actor.id,
130
+ },
131
+ position: { x: 0, y: 0 },
132
+ });
133
+
134
+ return nodeId;
135
+ };
136
+
137
+ const addSystemNode = (system: CollectionEntry<'systems'>) => {
138
+ const nodeId = generateIdForNode(system);
139
+ systemNodeIds.add(nodeId);
140
+ if (nodes.has(nodeId)) return nodeId;
141
+
142
+ nodes.set(nodeId, {
143
+ id: nodeId,
144
+ type: 'systems',
145
+ sourcePosition: 'right',
146
+ targetPosition: 'left',
147
+ data: {
148
+ mode,
149
+ system: { ...system.data },
150
+ // Counts of what the system groups, shown as a stats row on the node.
151
+ // The raw frontmatter arrays are pointer lists, so their length is the count.
152
+ servicesCount: (system.data.services || []).length,
153
+ entitiesCount: (system.data.entities || []).length,
154
+ containersCount: (system.data.containers || []).length,
155
+ messagesCount: countMessagesForSystem(system),
156
+ contextMenu: buildContextMenuForSystem({ id: system.data.id, version: system.data.version }),
157
+ },
158
+ position: { x: 0, y: 0 },
159
+ });
160
+
161
+ return nodeId;
162
+ };
163
+
164
+ const addSystemRelationshipEdge = ({
165
+ sourceNodeId,
166
+ targetNodeId,
167
+ label,
168
+ }: {
169
+ sourceNodeId: string;
170
+ targetNodeId: string;
171
+ label?: string;
172
+ }) => {
173
+ if (!label) return;
174
+
175
+ const edgeId = `${sourceNodeId}-${targetNodeId}`;
176
+ edges.set(
177
+ edgeId,
178
+ createEdge({
179
+ id: edgeId,
180
+ source: sourceNodeId,
181
+ target: targetNodeId,
182
+ label,
183
+ type: 'straight',
184
+ markerEnd: { type: MarkerType.ArrowClosed, width: 20, height: 20 },
185
+ })
186
+ );
187
+ };
188
+
189
+ // Breadth-first traversal over the relationship graph, following each system's
190
+ // `relationships` to other systems and reverse relationships from other systems.
191
+ // We visit each system once.
192
+ const visited = new Set<string>();
193
+ const queue: CollectionEntry<'systems'>[] = [...seedSystems];
194
+
195
+ while (queue.length > 0) {
196
+ const system = queue.shift() as CollectionEntry<'systems'>;
197
+ const sourceNodeId = generateIdForNode(system);
198
+
199
+ if (visited.has(sourceNodeId)) continue;
200
+ visited.add(sourceNodeId);
201
+
202
+ addSystemNode(system);
203
+
204
+ const relationships = (system.data.relationships || []) as SystemRelationship[];
205
+
206
+ for (const relationship of relationships) {
207
+ const target = findInMap(systemMap, relationship.id, relationship.version);
208
+ // Skip dangling targets — degrade gracefully like the other pointer types.
209
+ if (!target) continue;
210
+
211
+ const targetNodeId = addSystemNode(target);
212
+
213
+ // Only draw an edge when the relationship declares a label. A relationship without a
214
+ // label still pulls the target system into the diagram, but has no edge drawn.
215
+ addSystemRelationshipEdge({ sourceNodeId, targetNodeId, label: relationship.label });
216
+
217
+ if (!visited.has(targetNodeId)) {
218
+ queue.push(target);
219
+ }
220
+ }
221
+
222
+ const incomingRelationships = incomingRelationshipsByTargetNodeId.get(sourceNodeId) || [];
223
+ for (const { source, relationship } of incomingRelationships) {
224
+ const incomingSourceNodeId = addSystemNode(source);
225
+
226
+ // Preserve relationship direction: the referring system remains the edge source,
227
+ // even though this traversal discovered it from the target side.
228
+ addSystemRelationshipEdge({
229
+ sourceNodeId: incomingSourceNodeId,
230
+ targetNodeId: sourceNodeId,
231
+ label: relationship.label,
232
+ });
233
+
234
+ if (!visited.has(incomingSourceNodeId)) {
235
+ queue.push(source);
236
+ }
237
+ }
238
+
239
+ // Actors that interact with this system. Each becomes an actor node with an
240
+ // edge whose direction depends on `direction` (inbound = actor -> system,
241
+ // outbound = system -> actor). Actors don't pull other systems into the graph.
242
+ const actors = (system.data.actors || []) as SystemActor[];
243
+
244
+ for (const actor of actors) {
245
+ const actorNodeId = addActorNode(actor);
246
+ const direction = actor.direction || 'inbound';
247
+ const source = direction === 'inbound' ? actorNodeId : sourceNodeId;
248
+ const target = direction === 'inbound' ? sourceNodeId : actorNodeId;
249
+
250
+ const edgeId = `${source}-${target}`;
251
+ if (edges.has(edgeId)) continue;
252
+
253
+ edges.set(
254
+ edgeId,
255
+ createEdge({
256
+ id: edgeId,
257
+ source,
258
+ target,
259
+ label: actor.label,
260
+ type: 'straight',
261
+ markerEnd: { type: MarkerType.ArrowClosed, width: 20, height: 20 },
262
+ })
263
+ );
264
+ }
265
+ }
266
+
267
+ // Collapse reciprocal system-to-system relationships. When system A relates to B
268
+ // and B also relates back to A, we'd otherwise draw two parallel edges. Instead we
269
+ // keep a single edge with arrowheads on both ends. Each direction's label is
270
+ // preserved (joined when they differ). Only system↔system edges are merged — actor
271
+ // edges are left untouched.
272
+ const mergedEdgeKeys = new Set<string>();
273
+ for (const edge of Array.from(edges.values())) {
274
+ if (mergedEdgeKeys.has(edge.id)) continue;
275
+ if (!systemNodeIds.has(edge.source) || !systemNodeIds.has(edge.target)) continue;
276
+
277
+ const reverseId = `${edge.target}-${edge.source}`;
278
+ const reverse = edges.get(reverseId);
279
+ if (!reverse || reverseId === edge.id) continue;
280
+
281
+ // Merge the two labels, keeping a single value when they match.
282
+ const labels = [edge.label, reverse.label].filter(Boolean);
283
+ const mergedLabel = labels.length > 1 && labels[0] !== labels[1] ? labels.join(' / ') : labels[0];
284
+
285
+ edges.set(edge.id, {
286
+ ...edge,
287
+ label: mergedLabel,
288
+ markerStart: { type: MarkerType.ArrowClosed, width: 20, height: 20 },
289
+ });
290
+ edges.delete(reverseId);
291
+ mergedEdgeKeys.add(edge.id);
292
+ mergedEdgeKeys.add(reverseId);
293
+ }
294
+
295
+ // Lay the graph out
296
+ nodes.forEach((node) =>
297
+ flow.setNode(node.id, {
298
+ width: actorNodeIds.has(node.id) ? ACTOR_NODE_WIDTH : SYSTEM_NODE_WIDTH,
299
+ height: DEFAULT_NODE_HEIGHT,
300
+ })
301
+ );
302
+ edges.forEach((edge) => flow.setEdge(edge.source, edge.target));
303
+
304
+ if (layout) {
305
+ dagre.layout(flow);
306
+ }
307
+
308
+ return {
309
+ nodes: calculatedNodes(flow, Array.from(nodes.values())),
310
+ edges: [...edges.values()],
311
+ };
312
+ };
313
+
314
+ /**
315
+ * Builds the System Diagram for a single system, seeding the traversal from
316
+ * that system and walking its relationships and actors outward.
317
+ */
318
+ export const getNodesAndEdges = async ({ id, version, defaultFlow, mode = 'simple', layout = true }: NodesAndEdgesProps) => {
319
+ const [allSystems, allServices] = await Promise.all([getCollection('systems'), getCollection('services')]);
320
+ const systemMap = createVersionedMap(allSystems);
321
+ const serviceMap = createVersionedMap(allServices);
322
+
323
+ const rootSystem = findInMap(systemMap, id, version);
324
+
325
+ // Nothing found...
326
+ if (!rootSystem) {
327
+ return { nodes: [], edges: [] };
328
+ }
329
+
330
+ return buildContextGraphFromSeeds({ seedSystems: [rootSystem], systemMap, serviceMap, defaultFlow, mode, layout });
331
+ };
332
+
333
+ /**
334
+ * Builds the System Diagram for a whole domain: seeds the traversal with every
335
+ * system the domain declares, then walks their relationships and actors. The result is
336
+ * the domain's systems plus any related systems they point at (or that point at them),
337
+ * with actors rendered inline — giving a single context view across the domain.
338
+ */
339
+ export const getNodesAndEdgesForDomainSystems = async ({
340
+ id,
341
+ version,
342
+ defaultFlow,
343
+ mode = 'simple',
344
+ layout = true,
345
+ }: NodesAndEdgesProps) => {
346
+ const [allDomains, allSystems, allServices] = await Promise.all([
347
+ getCollection('domains'),
348
+ getCollection('systems'),
349
+ getCollection('services'),
350
+ ]);
351
+ const domainMap = createVersionedMap(allDomains);
352
+ const systemMap = createVersionedMap(allSystems);
353
+ const serviceMap = createVersionedMap(allServices);
354
+
355
+ const domain = findInMap(domainMap, id, version);
356
+
357
+ // Nothing found, or the domain has no systems to seed from.
358
+ if (!domain) {
359
+ return { nodes: [], edges: [] };
360
+ }
361
+
362
+ const seedSystems = (domain.data.systems || [])
363
+ .map((ref: { id: string; version?: string }) => findInMap(systemMap, ref.id, ref.version))
364
+ .filter((system): system is CollectionEntry<'systems'> => !!system);
365
+
366
+ if (seedSystems.length === 0) {
367
+ return { nodes: [], edges: [] };
368
+ }
369
+
370
+ return buildContextGraphFromSeeds({ seedSystems, systemMap, serviceMap, defaultFlow, mode, layout });
371
+ };
372
+
373
+ interface AllSystemsProps {
374
+ defaultFlow?: DagreGraph;
375
+ mode?: 'simple' | 'full';
376
+ layout?: boolean;
377
+ }
378
+
379
+ /**
380
+ * Builds a global System Context Map for the whole catalog: every system becomes a
381
+ * seed, so the traversal draws all systems, the relationships between them, and the
382
+ * actors around them — a single map of how every system in the catalog relates.
383
+ */
384
+ export const getNodesAndEdgesForAllSystems = async ({ defaultFlow, mode = 'simple', layout = true }: AllSystemsProps = {}) => {
385
+ const [allSystems, allServices] = await Promise.all([getCollection('systems'), getCollection('services')]);
386
+ const systemMap = createVersionedMap(allSystems);
387
+ const serviceMap = createVersionedMap(allServices);
388
+
389
+ // Seed with the latest version of every system. `systemMap` sorts each entry so
390
+ // index [0] is the latest, which avoids drawing every historical version.
391
+ const seedSystems = Array.from(systemMap.values())
392
+ .map((versions) => versions[0])
393
+ .filter((system): system is CollectionEntry<'systems'> => !!system);
394
+
395
+ if (seedSystems.length === 0) {
396
+ return { nodes: [], edges: [] };
397
+ }
398
+
399
+ return buildContextGraphFromSeeds({ seedSystems, systemMap, serviceMap, defaultFlow, mode, layout });
400
+ };