@eventcatalog/core 5.0.0-beta.2 → 5.0.0-beta.20

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 (204) hide show
  1. package/LICENSE +234 -134
  2. package/README.md +5 -5
  3. package/dist/analytics/analytics.cjs +18 -3
  4. package/dist/analytics/analytics.d.cts +1 -1
  5. package/dist/analytics/analytics.d.ts +1 -1
  6. package/dist/analytics/analytics.js +3 -2
  7. package/dist/analytics/log-build.cjs +71 -35
  8. package/dist/analytics/log-build.js +5 -4
  9. package/dist/analytics/telemetry.cjs +42 -0
  10. package/dist/analytics/telemetry.d.cts +20 -0
  11. package/dist/analytics/telemetry.d.ts +20 -0
  12. package/dist/analytics/telemetry.js +8 -0
  13. package/dist/catalog-runtime.cjs +1 -1
  14. package/dist/{chunk-HGRFXTFC.js → chunk-3VQSVQTU.js} +1 -1
  15. package/dist/{chunk-KIDISU6X.js → chunk-5ZUN2TXB.js} +56 -33
  16. package/dist/{chunk-TQLRG35T.js → chunk-AWOO63ME.js} +71 -45
  17. package/dist/chunk-CPW6G62V.js +17 -0
  18. package/dist/{chunk-4J2ZM2HX.js → chunk-F236OJZD.js} +1 -1
  19. package/dist/{chunk-XARPIXWF.js → chunk-FWH5T6U4.js} +9 -3
  20. package/dist/{chunk-62EV4IJA.js → chunk-R2X4ZEF6.js} +1 -1
  21. package/dist/constants.cjs +1 -1
  22. package/dist/constants.js +1 -1
  23. package/dist/eventcatalog.cjs +214 -129
  24. package/dist/eventcatalog.config.d.cts +59 -1
  25. package/dist/eventcatalog.config.d.ts +59 -1
  26. package/dist/eventcatalog.js +39 -15
  27. package/dist/federation/federate.js +2 -2
  28. package/dist/generate.cjs +1 -1
  29. package/dist/generate.js +3 -3
  30. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio-fonts.css.mjs +3 -0
  31. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.css.mjs +3 -0
  32. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.mjs +3 -0
  33. package/dist/runtime-dependencies/@hocuspocus__provider.mjs +3 -0
  34. package/dist/runtime-dependencies/@hocuspocus__server.mjs +3 -0
  35. package/dist/runtime-dependencies/@mcp-b__webmcp-local-relay.mjs +3 -0
  36. package/dist/runtime-dependencies/@mcp-b__webmcp-polyfill.mjs +3 -0
  37. package/dist/runtime-dependencies/htmlparser2.mjs +3 -0
  38. package/dist/runtime-dependencies/manifest.json +13 -1
  39. package/dist/runtime-dependencies/react-hotkeys-hook.mjs +3 -0
  40. package/dist/runtime-dependencies/react-syntax-highlighter__dist__cjs__create-element.js.mjs +3 -0
  41. package/dist/runtime-dependencies/y-protocols.mjs +3 -0
  42. package/dist/runtime-dependencies/y-protocols__awareness.mjs +3 -0
  43. package/dist/runtime-dependencies/yjs.mjs +3 -0
  44. package/dist/utils/cli-logger.cjs +1 -1
  45. package/dist/utils/cli-logger.js +2 -2
  46. package/dist/utils/license-status.cjs +71 -45
  47. package/dist/utils/license-status.d.cts +14 -5
  48. package/dist/utils/license-status.d.ts +14 -5
  49. package/dist/utils/license-status.js +1 -1
  50. package/eventcatalog/astro.config.mjs +23 -1
  51. package/eventcatalog/integrations/skip-startup-reload.mjs +31 -0
  52. package/eventcatalog/integrations/stable-dependency-order.mjs +29 -0
  53. package/eventcatalog/integrations/storage.mjs +20 -0
  54. package/eventcatalog/integrations/studio-server.mjs +32 -0
  55. package/eventcatalog/src/components/CatalogGraph/CatalogForceGraph.tsx +39 -0
  56. package/eventcatalog/src/components/ChatPanel/ChatPanel.tsx +2 -0
  57. package/eventcatalog/src/components/Grids/utils.tsx +0 -23
  58. package/eventcatalog/src/components/Header.astro +3 -3
  59. package/eventcatalog/src/components/MDX/CodeGroup/CodeGroup.tsx +6 -1
  60. package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +42 -3
  61. package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +5 -1
  62. package/eventcatalog/src/components/NavigationProgress.astro +7 -0
  63. package/eventcatalog/src/components/SchemaExplorer/ApiContentViewer.tsx +2 -0
  64. package/eventcatalog/src/components/SchemaExplorer/ExamplesViewer.tsx +2 -0
  65. package/eventcatalog/src/components/SchemaExplorer/SchemaCodeModal.tsx +2 -0
  66. package/eventcatalog/src/components/SchemaExplorer/SchemaContentViewer.tsx +2 -0
  67. package/eventcatalog/src/components/SchemaExplorer/SchemaDetailsPanel.tsx +12 -4
  68. package/eventcatalog/src/components/Search/SearchDataLoader.astro +16 -28
  69. package/eventcatalog/src/components/Search/SearchModal.tsx +2 -10
  70. package/eventcatalog/src/components/Search/search-utils.ts +26 -0
  71. package/eventcatalog/src/components/Seo.astro +5 -1
  72. package/eventcatalog/src/components/SideNav/NestedSideBar/index.tsx +53 -83
  73. package/eventcatalog/src/components/SideNav/NestedSideBar/storage.ts +2 -0
  74. package/eventcatalog/src/components/SideNav/NestedSideBar/utils.ts +131 -0
  75. package/eventcatalog/src/components/SideNav/SideNav.astro +9 -1
  76. package/eventcatalog/src/components/Tables/Discover/DiscoverTable.tsx +4 -62
  77. package/eventcatalog/src/components/Tables/Discover/columns.tsx +17 -150
  78. package/eventcatalog/src/components/Tables/Resources/ResourcesTable.tsx +425 -0
  79. package/eventcatalog/src/components/Tables/RowActionsMenu.tsx +157 -0
  80. package/eventcatalog/src/components/Tables/TablePagination.tsx +82 -0
  81. package/eventcatalog/src/features/ai/chat-api.ts +3 -3
  82. package/eventcatalog/src/features/auth/error.astro +4 -2
  83. package/eventcatalog/src/features/auth/login.astro +2 -1
  84. package/eventcatalog/src/features/auth/middleware/middleware-auth.ts +10 -0
  85. package/eventcatalog/src/features/auth/unauthorized.astro +1 -1
  86. package/eventcatalog/src/features/fields/pages/fields.astro +1 -1
  87. package/eventcatalog/src/features/integrations/eventcatalog-features.ts +34 -1
  88. package/eventcatalog/src/features/mcp/apps/architecture-diagram/node-chat.tsx +6 -45
  89. package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +47 -0
  90. package/eventcatalog/src/features/mcp/apps/canvas/styles.css +24 -0
  91. package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +278 -0
  92. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +272 -0
  93. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +77 -76
  94. package/eventcatalog/src/features/mcp/apps/shared/app-view.tsx +2 -1
  95. package/eventcatalog/src/features/mcp/mcp-apps.ts +6 -3
  96. package/eventcatalog/src/features/mcp/mcp-server.ts +36 -5
  97. package/eventcatalog/src/features/server/start.ts +25 -0
  98. package/eventcatalog/src/features/storage/database.ts +88 -0
  99. package/eventcatalog/src/features/storage/migrations.ts +85 -0
  100. package/eventcatalog/src/features/storage/sql/database.sql +5 -0
  101. package/eventcatalog/src/features/storage/sql/migrations/0001-studio-canvases.sql +12 -0
  102. package/eventcatalog/src/features/storage/sql/migrations.sql +15 -0
  103. package/eventcatalog/src/features/storage/sql.ts +28 -0
  104. package/eventcatalog/src/features/studio/agent-choreography.ts +384 -0
  105. package/eventcatalog/src/features/studio/agents.ts +28 -0
  106. package/eventcatalog/src/features/studio/api/canvases-api.ts +219 -0
  107. package/eventcatalog/src/features/studio/api/canvases.ts +25 -0
  108. package/eventcatalog/src/features/studio/api/client.ts +40 -0
  109. package/eventcatalog/src/features/studio/canvas-actions.ts +540 -0
  110. package/eventcatalog/src/features/studio/canvas-preview.ts +52 -0
  111. package/eventcatalog/src/features/studio/catalog-resources.ts +155 -0
  112. package/eventcatalog/src/features/studio/catalog.ts +173 -0
  113. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +518 -0
  114. package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +118 -0
  115. package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +434 -0
  116. package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +143 -0
  117. package/eventcatalog/src/features/studio/components/DeleteCanvasDialog.tsx +65 -0
  118. package/eventcatalog/src/features/studio/components/Dialog.tsx +60 -0
  119. package/eventcatalog/src/features/studio/components/JoinForm.tsx +109 -0
  120. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +203 -0
  121. package/eventcatalog/src/features/studio/components/NodeDetails.tsx +183 -0
  122. package/eventcatalog/src/features/studio/components/OpenInStudioDialog.tsx +79 -0
  123. package/eventcatalog/src/features/studio/components/Presence.tsx +293 -0
  124. package/eventcatalog/src/features/studio/components/ShareDialog.tsx +396 -0
  125. package/eventcatalog/src/features/studio/components/ShortcutsDialog.tsx +41 -0
  126. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +2091 -0
  127. package/eventcatalog/src/features/studio/components/StudioNotice.astro +24 -0
  128. package/eventcatalog/src/features/studio/document-name.ts +7 -0
  129. package/eventcatalog/src/features/studio/from-visualiser.ts +138 -0
  130. package/eventcatalog/src/features/studio/hooks/presence-sender.ts +63 -0
  131. package/eventcatalog/src/features/studio/hooks/presence-store.ts +119 -0
  132. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +353 -0
  133. package/eventcatalog/src/features/studio/hooks/use-comments.ts +54 -0
  134. package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +109 -0
  135. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +782 -0
  136. package/eventcatalog/src/features/studio/identity.ts +59 -0
  137. package/eventcatalog/src/features/studio/limits.ts +2 -0
  138. package/eventcatalog/src/features/studio/pages/[id].astro +69 -0
  139. package/eventcatalog/src/features/studio/pages/index.astro +784 -0
  140. package/eventcatalog/src/features/studio/pages/webmcp-relay.ts +27 -0
  141. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +495 -0
  142. package/eventcatalog/src/features/studio/server/runtime.ts +498 -0
  143. package/eventcatalog/src/features/studio/server/sign-in.ts +18 -0
  144. package/eventcatalog/src/features/studio/server/sql/storage.sql +18 -0
  145. package/eventcatalog/src/features/studio/server/storage.ts +90 -0
  146. package/eventcatalog/src/features/studio/server/tool-icons.ts +30 -0
  147. package/eventcatalog/src/features/studio/tool-sync.ts +75 -0
  148. package/eventcatalog/src/layouts/BaseLayout.astro +4 -4
  149. package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +13 -7
  150. package/eventcatalog/src/pages/_index.astro +1 -1
  151. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.spec.ts +52 -63
  152. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.ts +34 -46
  153. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/index.astro +11 -29
  154. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_ResourcesPage.astro +79 -0
  155. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_index.data.spec.ts +32 -14
  156. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_index.data.ts +63 -58
  157. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/index.astro +2 -62
  158. package/eventcatalog/src/pages/docs/[type]/[id]/index.astro +1 -1
  159. package/eventcatalog/src/pages/docs/services/[id]/[version]/resources/index.astro +13 -0
  160. package/eventcatalog/src/pages/index.astro +1 -1
  161. package/eventcatalog/src/pages/schemas/explorer/index.astro +1 -1
  162. package/eventcatalog/src/pages/visualiser/[type]/[id]/index.astro +1 -1
  163. package/eventcatalog/src/stores/sidebar-store/builders/domain.ts +24 -42
  164. package/eventcatalog/src/stores/sidebar-store/builders/flow.ts +2 -0
  165. package/eventcatalog/src/stores/sidebar-store/builders/message.ts +2 -0
  166. package/eventcatalog/src/stores/sidebar-store/builders/service.ts +23 -23
  167. package/eventcatalog/src/stores/sidebar-store/builders/shared.ts +13 -0
  168. package/eventcatalog/src/stores/sidebar-store/builders/system.ts +18 -27
  169. package/eventcatalog/src/stores/sidebar-store/state.ts +5 -0
  170. package/eventcatalog/src/styles/tailwind.css +2 -2
  171. package/eventcatalog/src/types/react-syntax-highlighter.d.ts +10 -0
  172. package/eventcatalog/src/utils/collections/channels.ts +56 -1
  173. package/eventcatalog/src/utils/collections/flows.ts +50 -1
  174. package/eventcatalog/src/utils/collections/message-connections.ts +60 -0
  175. package/eventcatalog/src/utils/collections/message-triggers.ts +2 -1
  176. package/eventcatalog/src/utils/collections/resource-owners.ts +193 -0
  177. package/eventcatalog/src/utils/collections/resources.ts +310 -0
  178. package/eventcatalog/src/utils/collections/util.ts +31 -0
  179. package/eventcatalog/src/utils/feature.ts +8 -0
  180. package/eventcatalog/src/utils/git.ts +11 -5
  181. package/eventcatalog/src/utils/link-validation.ts +25 -19
  182. package/eventcatalog/src/utils/node-graphs/domain-entity-map.ts +3 -1
  183. package/eventcatalog/src/utils/node-graphs/domain-levels-node-graph.ts +3 -3
  184. package/eventcatalog/src/utils/node-graphs/export-mermaid.ts +0 -2
  185. package/eventcatalog/src/utils/node-graphs/layout-node-graph.ts +20 -1
  186. package/eventcatalog/src/utils/node-graphs/node-resource.ts +67 -0
  187. package/eventcatalog/src/utils/node-graphs/systems-node-graph.ts +1 -1
  188. package/eventcatalog/src/utils/page-title.ts +15 -0
  189. package/eventcatalog/src/utils/syntax-highlighter-renderer.ts +64 -0
  190. package/eventcatalog/src/utils/trial.ts +1 -1
  191. package/package.json +22 -16
  192. package/dist/runtime-dependencies/shelljs.mjs +0 -3
  193. package/eventcatalog/public/studio-bg.png +0 -0
  194. package/eventcatalog/src/components/Grids/DomainGrid.tsx +0 -838
  195. package/eventcatalog/src/components/Grids/MessageGrid.tsx +0 -344
  196. package/eventcatalog/src/components/Grids/SystemGrid.tsx +0 -185
  197. package/eventcatalog/src/components/Grids/components.tsx +0 -170
  198. package/eventcatalog/src/components/Grids/message-link.ts +0 -38
  199. package/eventcatalog/src/components/Studio/StudioPageModal.tsx +0 -150
  200. package/eventcatalog/src/components/Tables/SystemResources/SystemResourcesTable.tsx +0 -294
  201. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_resources.spec.ts +0 -65
  202. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_resources.ts +0 -46
  203. package/eventcatalog/src/pages/studio.astro +0 -316
  204. package/dist/{chunk-AYNZXUB5.js → chunk-KHACCTW2.js} +3 -3
@@ -0,0 +1,310 @@
1
+ import { collectionToResourceMap, resourceKey, uniqueResources } from '@utils/collections/util';
2
+
3
+ // What the Resources pages show: which resources each owner (domain, system, service, flow,
4
+ // channel or message) lists, how each relates to the owner, and the links for each row.
5
+ // Loading owners and their related resources lives in ./resource-owners.
6
+
7
+ /** The kinds of resource a Resources page can list. */
8
+ export type ResourceCollection =
9
+ | 'domains'
10
+ | 'systems'
11
+ | 'agents'
12
+ | 'services'
13
+ | 'flows'
14
+ | 'entities'
15
+ | 'data-products'
16
+ | 'containers'
17
+ | 'events'
18
+ | 'commands'
19
+ | 'queries'
20
+ | 'channels'
21
+ | 'adrs';
22
+
23
+ /** How a resource relates to the page's owner: which way a message flows, how a data store is used, or how it belongs to the owner. */
24
+ export type ResourceRelationship =
25
+ | 'receives'
26
+ | 'sends'
27
+ | 'sends-and-receives'
28
+ | 'reads'
29
+ | 'writes'
30
+ | 'reads-and-writes'
31
+ | 'contains'
32
+ | 'owns'
33
+ | 'appears-in'
34
+ | 'governed-by'
35
+ | 'includes'
36
+ | 'transports'
37
+ | 'produces-messages'
38
+ | 'receives-messages'
39
+ | 'produces-and-receives-messages'
40
+ | 'produces'
41
+ | 'consumes'
42
+ | 'produces-and-consumes'
43
+ | 'triggers'
44
+ | 'triggered-by'
45
+ | 'triggers-and-triggered-by';
46
+
47
+ /** Any catalog entry a Resources page reads: a collection entry, possibly hydrated with extra fields. */
48
+ export type ResourceEntry = {
49
+ collection: string;
50
+ data: { id: string; version: string; name?: string; summary?: string; [key: string]: any };
51
+ [key: string]: any;
52
+ };
53
+
54
+ export type ResourceOwnerCollection = 'systems' | 'domains' | 'services' | 'flows' | 'channels' | MessageOwnerCollection;
55
+ type MessageOwnerCollection = 'events' | 'commands' | 'queries';
56
+ const isMessageOwner = (collection: ResourceOwnerCollection): collection is MessageOwnerCollection =>
57
+ collection === 'events' || collection === 'commands' || collection === 'queries';
58
+
59
+ /** Resources that point at the owner, rather than being listed in the owner's own frontmatter. */
60
+ export interface RelatedResources {
61
+ /** Decision records whose `appliesTo` includes the owner. */
62
+ adrs?: ResourceEntry[];
63
+ /** Flows with a step that points at the owner (services only). */
64
+ stepFlows?: ResourceEntry[];
65
+ /** The resources a flow's steps point at (flows only). */
66
+ stepResources?: ResourceEntry[];
67
+ /** Services and agents that send to the channel, those that receive from it, and the messages on it (channels only). */
68
+ channelProducers?: ResourceEntry[];
69
+ channelConsumers?: ResourceEntry[];
70
+ channelMessages?: ResourceEntry[];
71
+ /** The channels, flows and messages a message connects to (messages only; its producers and consumers are on `data`). */
72
+ messageChannels?: ResourceEntry[];
73
+ messageFlows?: ResourceEntry[];
74
+ messageTriggers?: ResourceEntry[];
75
+ messageTriggeredBy?: ResourceEntry[];
76
+ }
77
+
78
+ /** A resource owner as the Resources page receives it: the collection entry with its related resources attached. */
79
+ export type ResourceOwner = ResourceEntry & RelatedResources;
80
+
81
+ export interface ResourceGroup {
82
+ collection: ResourceCollection;
83
+ items: ResourceEntry[];
84
+ }
85
+
86
+ const getMessages = (data: any) =>
87
+ uniqueResources([...(data.sends || []), ...(data.receives || [])].filter((message) => message?.collection));
88
+
89
+ const messageGroups = (messages: any[]): ResourceGroup[] => [
90
+ { collection: 'events', items: messages.filter((message) => message.collection === 'events') },
91
+ { collection: 'commands', items: messages.filter((message) => message.collection === 'commands') },
92
+ { collection: 'queries', items: messages.filter((message) => message.collection === 'queries') },
93
+ ];
94
+
95
+ // The collections a flow step can point at, in the order the page groups them.
96
+ const FLOW_STEP_COLLECTIONS: ResourceCollection[] = [
97
+ 'services',
98
+ 'agents',
99
+ 'flows',
100
+ 'containers',
101
+ 'data-products',
102
+ 'events',
103
+ 'commands',
104
+ 'queries',
105
+ ];
106
+
107
+ export const getResourceGroups = (
108
+ data: any,
109
+ ownerCollection: ResourceOwnerCollection,
110
+ related: RelatedResources = {}
111
+ ): ResourceGroup[] => {
112
+ const adrGroup: ResourceGroup = { collection: 'adrs', items: related.adrs || [] };
113
+
114
+ // A message's resources are what produces or consumes it, the channels it travels on, the
115
+ // flows it appears in, and the messages it triggers or is triggered by.
116
+ if (isMessageOwner(ownerCollection)) {
117
+ const endpoints = uniqueResources([...(data.producers || []), ...(data.consumers || [])]);
118
+ const endpointGroup = (collection: ResourceCollection): ResourceGroup => ({
119
+ collection,
120
+ items: endpoints.filter((endpoint) => endpoint.collection === collection),
121
+ });
122
+ return [
123
+ endpointGroup('services'),
124
+ endpointGroup('agents'),
125
+ endpointGroup('data-products'),
126
+ { collection: 'channels', items: related.messageChannels || [] },
127
+ { collection: 'flows', items: related.messageFlows || [] },
128
+ ...messageGroups(uniqueResources([...(related.messageTriggers || []), ...(related.messageTriggeredBy || [])])),
129
+ adrGroup,
130
+ ];
131
+ }
132
+
133
+ // A channel's resources are the services and agents that send to or receive from it, and the messages on it.
134
+ if (ownerCollection === 'channels') {
135
+ const endpoints = uniqueResources([...(related.channelProducers || []), ...(related.channelConsumers || [])]);
136
+ return [
137
+ { collection: 'services', items: endpoints.filter((endpoint) => endpoint.collection === 'services') },
138
+ { collection: 'agents', items: endpoints.filter((endpoint) => endpoint.collection === 'agents') },
139
+ ...messageGroups(related.channelMessages || []),
140
+ adrGroup,
141
+ ];
142
+ }
143
+
144
+ // A flow's resources are what its steps point at.
145
+ if (ownerCollection === 'flows') {
146
+ const stepResources = related.stepResources || [];
147
+ return [
148
+ ...FLOW_STEP_COLLECTIONS.map((collection) => ({
149
+ collection,
150
+ items: stepResources.filter((resource) => resource.collection === collection),
151
+ })),
152
+ adrGroup,
153
+ ];
154
+ }
155
+
156
+ // A service's resources are what it uses: the messages it sends and receives, its
157
+ // entities, the data stores it reads from or writes to, and the flows it appears in.
158
+ if (ownerCollection === 'services') {
159
+ return [
160
+ { collection: 'flows', items: uniqueResources([...(data.flows || []), ...(related.stepFlows || [])]) },
161
+ { collection: 'entities', items: data.entities || [] },
162
+ { collection: 'containers', items: uniqueResources([...(data.writesTo || []), ...(data.readsFrom || [])]) },
163
+ ...messageGroups(getMessages(data)),
164
+ adrGroup,
165
+ ];
166
+ }
167
+
168
+ const domainOnlyGroups: ResourceGroup[] =
169
+ ownerCollection === 'domains'
170
+ ? [
171
+ { collection: 'domains', items: data.domains || [] },
172
+ { collection: 'systems', items: data.systems || [] },
173
+ { collection: 'agents', items: data.agents || [] },
174
+ { collection: 'data-products', items: data['data-products'] || [] },
175
+ ]
176
+ : [];
177
+
178
+ return [
179
+ ...domainOnlyGroups,
180
+ { collection: 'services', items: data.services || [] },
181
+ { collection: 'flows', items: data.flows || [] },
182
+ { collection: 'entities', items: data.entities || [] },
183
+ { collection: 'containers', items: data.containers || [] },
184
+ ...messageGroups(ownerCollection === 'domains' ? getMessages(data) : []),
185
+ adrGroup,
186
+ ];
187
+ };
188
+
189
+ // The page loader attaches the RelatedResources fields to the owner (see _index.data.ts).
190
+ export const hasResources = (owner: any, ownerCollection: ResourceOwnerCollection): boolean =>
191
+ getResourceGroups(owner?.data ?? {}, ownerCollection, owner ?? {}).some(({ items }) => items.length > 0);
192
+
193
+ const includesResource = (resources: any[] | undefined, item: any) =>
194
+ (resources || []).some((resource) => resource?.data && resourceKey(resource) === resourceKey(item));
195
+
196
+ /**
197
+ * How a resource relates to the page's owner: which way a message flows, how a data store
198
+ * is used, or whether the owner contains, owns or appears in it.
199
+ */
200
+ export const getResourceRelationship = (
201
+ data: any,
202
+ ownerCollection: ResourceOwnerCollection,
203
+ collection: ResourceCollection,
204
+ item: any,
205
+ related: RelatedResources = {}
206
+ ): ResourceRelationship | undefined => {
207
+ if (collection === 'adrs') return 'governed-by';
208
+ if (ownerCollection === 'flows') return 'includes';
209
+ if (isMessageOwner(ownerCollection)) {
210
+ if (collection === 'channels') return 'transports';
211
+ if (collection === 'flows') return 'appears-in';
212
+ if (collection === 'events' || collection === 'commands' || collection === 'queries') {
213
+ const triggers = includesResource(related.messageTriggers, item);
214
+ const triggeredBy = includesResource(related.messageTriggeredBy, item);
215
+ if (triggers && triggeredBy) return 'triggers-and-triggered-by';
216
+ if (triggers) return 'triggers';
217
+ if (triggeredBy) return 'triggered-by';
218
+ return undefined;
219
+ }
220
+ const produces = includesResource(data.producers, item);
221
+ const consumes = includesResource(data.consumers, item);
222
+ if (produces && consumes) return 'produces-and-consumes';
223
+ if (produces) return 'produces';
224
+ if (consumes) return 'consumes';
225
+ return undefined;
226
+ }
227
+ // A channel transports messages; services and agents produce messages onto it or receive them from it.
228
+ if (ownerCollection === 'channels') {
229
+ if (collection !== 'services' && collection !== 'agents') return 'transports';
230
+ const produces = includesResource(related.channelProducers, item);
231
+ const receives = includesResource(related.channelConsumers, item);
232
+ if (produces && receives) return 'produces-and-receives-messages';
233
+ if (produces) return 'produces-messages';
234
+ if (receives) return 'receives-messages';
235
+ return undefined;
236
+ }
237
+ if (collection === 'events' || collection === 'commands' || collection === 'queries') {
238
+ const sends = includesResource(data.sends, item);
239
+ const receives = includesResource(data.receives, item);
240
+ if (sends && receives) return 'sends-and-receives';
241
+ if (sends) return 'sends';
242
+ if (receives) return 'receives';
243
+ return undefined;
244
+ }
245
+ if (collection === 'entities') return 'owns';
246
+ if (ownerCollection === 'services') {
247
+ if (collection === 'flows') return 'appears-in';
248
+ if (collection === 'containers') {
249
+ const writes = includesResource(data.writesTo, item);
250
+ const reads = includesResource(data.readsFrom, item);
251
+ if (writes && reads) return 'reads-and-writes';
252
+ if (writes) return 'writes';
253
+ if (reads) return 'reads';
254
+ }
255
+ return undefined;
256
+ }
257
+ // Everything else on a domain or system page is part of it.
258
+ return 'contains';
259
+ };
260
+
261
+ /**
262
+ * The schema page for a resource, matching the sidebar's Schema link: messages with an entry
263
+ * in the schemas collection (keyed `collection:id:version`), and data products with an output
264
+ * contract. Undefined for everything else.
265
+ */
266
+ export const getSchemaPath = (collection: ResourceCollection, item: any, schemaKeys: Set<string>) => {
267
+ const { id, version } = item.data;
268
+ if (collection === 'events' || collection === 'commands' || collection === 'queries') {
269
+ return schemaKeys.has(`${collection}:${id}:${version}`) ? `/schemas/${collection}/${id}/${version}` : undefined;
270
+ }
271
+ if (collection === 'data-products') {
272
+ const contract = (item.data.outputs || []).find((output: any) => output.contract)?.contract;
273
+ return contract ? `/schemas/data-products/${id}/${version}?contract=${encodeURIComponent(contract.path)}` : undefined;
274
+ }
275
+ return undefined;
276
+ };
277
+
278
+ export const getDocsPath = (collection: ResourceCollection, item: any) =>
279
+ `/docs/${collection}/${item.data.id}/${item.data.version}`;
280
+
281
+ // Collections the visualiser has a page for (domains and systems through their own routes).
282
+ // Entities and decision records have none.
283
+ const VISUALISER_COLLECTIONS = new Set<ResourceCollection>([
284
+ 'domains',
285
+ 'systems',
286
+ 'agents',
287
+ 'services',
288
+ 'flows',
289
+ 'containers',
290
+ 'channels',
291
+ 'data-products',
292
+ 'events',
293
+ 'commands',
294
+ 'queries',
295
+ ]);
296
+
297
+ /** The resource's visualiser page, unless the visualiser is off, has no page for it, or the resource opts out. */
298
+ export const getVisualiserPath = (collection: ResourceCollection, item: any, visualiserEnabled: boolean) => {
299
+ if (!visualiserEnabled || !VISUALISER_COLLECTIONS.has(collection) || item.data.visualiser === false) return undefined;
300
+ return `/visualiser/${collection}/${item.data.id}/${item.data.version}`;
301
+ };
302
+
303
+ /** Same key and badge as the favorite button on the resource's docs page. */
304
+ export const getFavorite = (collection: ResourceCollection, item: any) => {
305
+ const resourceType = collectionToResourceMap[collection];
306
+ return {
307
+ nodeKey: `${resourceType}:${item.data.id}:${item.data.version}`,
308
+ badge: resourceType.charAt(0).toUpperCase() + resourceType.slice(1),
309
+ };
310
+ };
@@ -291,6 +291,37 @@ export const createVersionedMap = <T extends { data: { id: string; version?: str
291
291
  return map;
292
292
  };
293
293
 
294
+ const versionedMapCache = new WeakMap<object, Map<string, any[]>>();
295
+
296
+ /**
297
+ * createVersionedMap, built once per list. Use it in helpers that search the same list once per
298
+ * resource, so a catalog-wide pass does not rebuild the map every time.
299
+ */
300
+ export const getVersionedMap = <T extends { data: { id: string; version?: string } }>(items: T[]): Map<string, T[]> => {
301
+ let map = versionedMapCache.get(items);
302
+ if (!map) {
303
+ map = createVersionedMap(items);
304
+ versionedMapCache.set(items, map);
305
+ }
306
+ return map as Map<string, T[]>;
307
+ };
308
+
309
+ type KeyedResource = { collection: string; data: { id: string; version?: string } };
310
+
311
+ /** Identifies a resource across collections: a service and an agent can share an id and version. */
312
+ export const resourceKey = (resource: KeyedResource) => `${resource.collection}:${resource.data.id}:${resource.data.version}`;
313
+
314
+ /** The resources without repeats (by collection, id and version), keeping the first of each in order. */
315
+ export const uniqueResources = <T extends KeyedResource>(resources: T[]): T[] => {
316
+ const seen = new Map<string, T>();
317
+ for (const resource of resources) {
318
+ if (!resource?.data?.id) continue;
319
+ const key = resourceKey(resource);
320
+ if (!seen.has(key)) seen.set(key, resource);
321
+ }
322
+ return [...seen.values()];
323
+ };
324
+
294
325
  // Merge as many given maps as you want
295
326
  export const mergeMaps = <T>(...maps: Map<string, T[]>[]): Map<string, T[]> => {
296
327
  return maps.reduce((acc, map) => {
@@ -27,4 +27,12 @@ export const isAuthEnabled = () =>
27
27
  (config?.auth?.enabled ?? false) && fs.existsSync(join(projectDirectory(), 'eventcatalog.auth.js')) && isSSR();
28
28
 
29
29
  export const isEventCatalogMCPEnabled = () => isSSR() && (config?.mcp?.enabled ?? true);
30
+
31
+ // Collaborative canvases (EventCatalog Studio, /studio) are an experiment: on the dev server (unless studio.enabled is
32
+ // false), and in production (server output) when studio.enabled is set. With sign-in on, only signed-in people can
33
+ // open them.
34
+ export const isCanvasEnabled = () => {
35
+ const enabled = config?.studio?.enabled;
36
+ return enabled !== false && (isDevMode() || (isSSR() && enabled === true));
37
+ };
30
38
  export const isEventCatalogMCPAuthEnabled = () => isEventCatalogMCPEnabled() && (config?.mcp?.auth?.enabled ?? false);
@@ -1,7 +1,6 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import shell from 'shelljs';
5
4
 
6
5
  type Commit = { timestamp: number; author: string };
7
6
 
@@ -9,10 +8,6 @@ const COMMIT_MARKER = '\x1e';
9
8
 
10
9
  const newestCommitsByRepository = new Map<string, Map<string, Commit> | undefined>();
11
10
 
12
- export function hasGit() {
13
- return !!shell.which('git');
14
- }
15
-
16
11
  const runGit = (args: string[]) => {
17
12
  try {
18
13
  return execFileSync('git', ['-c', 'log.showSignature=false', ...args], {
@@ -25,6 +20,17 @@ const runGit = (args: string[]) => {
25
20
  }
26
21
  };
27
22
 
23
+ // Checked once per PATH, as git history is read for every page
24
+ const gitInstalledByPath = new Map<string | undefined, boolean>();
25
+
26
+ export function hasGit() {
27
+ const searchPath = process.env.PATH;
28
+ if (!gitInstalledByPath.has(searchPath)) {
29
+ gitInstalledByPath.set(searchPath, runGit(['--version']) !== undefined);
30
+ }
31
+ return gitInstalledByPath.get(searchPath)!;
32
+ }
33
+
28
34
  const parseCommit = (header: string): Commit => {
29
35
  const [timestamp, ...author] = header.split(',');
30
36
  return { timestamp: Number(timestamp) * 1_000, author: author.join(',') };
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { parse, type DefaultTreeAdapterMap } from 'parse5';
3
+ import { Parser } from 'htmlparser2';
4
4
  import picomatch from 'picomatch';
5
5
 
6
6
  export type LinkValidationSeverity = 'warn' | 'error' | 'ignore';
@@ -42,30 +42,36 @@ const decodeUrlPart = (value: string): string => {
42
42
  }
43
43
  };
44
44
 
45
+ // Elements whose contents browsers don't render as markup (<noscript> when scripts run)
46
+ const INERT_ELEMENTS = new Set(['template', 'noscript']);
47
+
45
48
  // Parse HTML rather than searching markup with a regex: attributes can contain
46
49
  // entities, quoted > characters, or single/unquoted values. Scripts and inert
47
50
  // template contents must not be mistaken for rendered links or anchor targets.
51
+ // A streaming parser, not a document tree: pages can be large (island props),
52
+ // and building a tree of each one made link validation a large part of a build.
48
53
  const readPage = (html: string, source: string): PageLinks => {
49
54
  const page: PageLinks = { source, anchors: new Set(), links: new Set() };
50
- const nodes: DefaultTreeAdapterMap['node'][] = [parse(html)];
51
- while (nodes.length > 0) {
52
- const node = nodes.pop()!;
53
- if ('tagName' in node) {
54
- const attrs = new Map(node.attrs.map((attr) => [attr.name, attr.value]));
55
- const id = attrs.get('id');
56
- if (id) page.anchors.add(id);
57
- if (node.tagName === 'a' && attrs.get('name')) page.anchors.add(attrs.get('name')!);
58
- const href = attrs.get('href');
59
- if (href !== undefined) {
60
- if (node.tagName === 'base' && page.baseHref === undefined) page.baseHref = href;
61
- if (node.tagName === 'a' || node.tagName === 'area') page.links.add(href);
55
+ let inert = 0;
56
+ const parser = new Parser({
57
+ onopentag(tagName, attrs) {
58
+ if (inert === 0) {
59
+ if (attrs.id) page.anchors.add(attrs.id);
60
+ if (tagName === 'a' && attrs.name) page.anchors.add(attrs.name);
61
+ const href = attrs.href;
62
+ if (href !== undefined) {
63
+ // Parsed in document order, so the first <base href> wins
64
+ if (tagName === 'base' && page.baseHref === undefined) page.baseHref = href;
65
+ if (tagName === 'a' || tagName === 'area') page.links.add(href);
66
+ }
62
67
  }
63
- }
64
- // Reverse the stack so the first <base href> wins in document order.
65
- if ('childNodes' in node) {
66
- for (let i = node.childNodes.length - 1; i >= 0; i--) nodes.push(node.childNodes[i]);
67
- }
68
- }
68
+ if (INERT_ELEMENTS.has(tagName)) inert++;
69
+ },
70
+ onclosetag(tagName) {
71
+ if (INERT_ELEMENTS.has(tagName) && inert > 0) inert--;
72
+ },
73
+ });
74
+ parser.end(html);
69
75
  return page;
70
76
  };
71
77
 
@@ -212,7 +212,9 @@ export const getNodesAndEdges = async ({ id, version, entities, type = 'domains'
212
212
  height: 20,
213
213
  },
214
214
  });
215
- } else {
215
+ } else if (!entityMap.has(referencedEntityId)) {
216
+ // Only a reference to an entity missing from the catalog is broken. An external entity's own
217
+ // references can point further away than this map shows: those edges are left out.
216
218
  console.warn(`Referenced entity "${referencedEntityId}" not found for ${entity.data.name}.${referenceProperty.name}`);
217
219
  }
218
220
  }
@@ -267,7 +267,7 @@ export const buildDomainGraph = ({
267
267
  draggable: false,
268
268
  selectable: false,
269
269
  data: {
270
- domain: { name: d.data.name, version: d.data.version },
270
+ domain: { id: d.data.id, name: d.data.name, version: d.data.version },
271
271
  // The domain viewed may itself be a subdomain of another
272
272
  subdomain: !!parentId || parentDomainOf.has(d.data.id),
273
273
  isFocused: !parentId,
@@ -309,7 +309,7 @@ export const buildDomainGraph = ({
309
309
  position: { x: 0, y: 0 },
310
310
  ...(parentId ? { parentId } : {}),
311
311
  data: {
312
- domain: { name: other.data.name, version: other.data.version },
312
+ domain: { id: other.data.id, name: other.data.name, version: other.data.version },
313
313
  subdomain: !!parentId,
314
314
  isFocused: false,
315
315
  otherDomain: true,
@@ -375,7 +375,7 @@ export const buildDomainGraph = ({
375
375
  type: 'system-group',
376
376
  parentId: domainGroup,
377
377
  position: { x: 0, y: 0 },
378
- data: { system: { name: system.data.name, version: system.data.version } },
378
+ data: { system: { id: system.data.id, name: system.data.name, version: system.data.version } },
379
379
  });
380
380
  }
381
381
  addNodes(graph.nodes);
@@ -38,7 +38,6 @@ const NODE_SHAPE_MAP: Record<string, [string, string]> = {
38
38
  'context-actor': ['((', '))'], // circle
39
39
  flows: ['([', '])'], // stadium (rounded)
40
40
  flow: ['([', '])'],
41
- systems: ['[[', ']]'],
42
41
  system: ['[[', ']]'],
43
42
  step: ['[', ']'], // rectangle
44
43
  user: ['((', '))'], // circle
@@ -79,7 +78,6 @@ const NODE_STYLE_CLASSES: Record<string, string> = {
79
78
  domains: 'fill:#eab308,stroke:#a16207,color:#000',
80
79
  domain: 'fill:#eab308,stroke:#a16207,color:#000',
81
80
  'context-domain': 'fill:#eab308,stroke:#a16207,color:#000',
82
- systems: 'fill:#7c3aed,stroke:#5b21b6,color:#fff',
83
81
  'context-actor': 'fill:#8b5cf6,stroke:#6d28d9,color:#fff',
84
82
  flows: 'fill:#14b8a6,stroke:#0f766e,color:#fff',
85
83
  flow: 'fill:#14b8a6,stroke:#0f766e,color:#fff',
@@ -1,7 +1,14 @@
1
1
  import { hideMessageNodes, layoutWithElk } from '@eventcatalog/visualiser/layout';
2
+ import { isSSR } from '@utils/feature';
2
3
 
3
4
  type Graph = { nodes: any[]; edges: any[] };
4
5
 
6
+ // A build renders one route at a time (every message's docs page, then every schema page), so the
7
+ // cache has to outlast a whole route to be reused. A server keeps running as the catalog changes and
8
+ // renders pages as they're asked for, so it keeps the most recent layouts only.
9
+ const MAX_CACHED_LAYOUTS = isSSR() ? 200 : 5000;
10
+ const layouts = new Map<string, ReturnType<typeof layoutWithElk>>();
11
+
5
12
  /**
6
13
  * Lays a node graph out for the visualiser (with ELK, left to right). Builders
7
14
  * create their nodes and edges without positions and lay the finished graph
@@ -9,7 +16,19 @@ type Graph = { nodes: any[]; edges: any[] };
9
16
  * (nodes with children) are sized to fit them, and edges get the route they're
10
17
  * drawn along (`data.route`), with room for their labels.
11
18
  */
12
- export const layoutNodeGraph = async <G extends Graph>(graph: G): Promise<G> => (await layoutWithElk(graph)) as unknown as G;
19
+ export const layoutNodeGraph = async <G extends Graph>(graph: G): Promise<G> => {
20
+ // A build lays the same graph out on several pages (a message's docs, visualiser and schema pages):
21
+ // reuse the layout. Each caller gets its own copy, so it can't change the one kept.
22
+ const key = JSON.stringify(graph);
23
+ let layout = layouts.get(key);
24
+ if (!layout) {
25
+ layout = layoutWithElk(graph);
26
+ layouts.set(key, layout);
27
+ layout.catch(() => layouts.delete(key));
28
+ if (layouts.size > MAX_CACHED_LAYOUTS) layouts.delete(layouts.keys().next().value!);
29
+ }
30
+ return structuredClone(await layout) as unknown as G;
31
+ };
13
32
 
14
33
  /**
15
34
  * Lays out the graphs of a levels page: level 1 (`overview`: domains, systems
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The catalog resource a diagram node shows, for the node types the node graphs build. Has no imports so it can
3
+ * be shared by the server and the browser (the visualiser's "Open in Studio", the MCP App views).
4
+ */
5
+
6
+ // The collection each node type shows a resource of
7
+ const NODE_COLLECTIONS: Record<string, string> = {
8
+ service: 'services',
9
+ services: 'services',
10
+ agent: 'agents',
11
+ agents: 'agents',
12
+ event: 'events',
13
+ events: 'events',
14
+ command: 'commands',
15
+ commands: 'commands',
16
+ query: 'queries',
17
+ queries: 'queries',
18
+ channel: 'channels',
19
+ channels: 'channels',
20
+ flow: 'flows',
21
+ flows: 'flows',
22
+ data: 'containers',
23
+ containers: 'containers',
24
+ 'data-products': 'data-products',
25
+ domain: 'domains',
26
+ domains: 'domains',
27
+ 'context-domain': 'domains',
28
+ 'domain-group': 'domains',
29
+ system: 'systems',
30
+ systems: 'systems',
31
+ 'system-group': 'systems',
32
+ };
33
+
34
+ // Where each node type keeps the resource it shows
35
+ const RESOURCE_DATA_KEYS = [
36
+ 'service',
37
+ 'agent',
38
+ 'message',
39
+ 'channel',
40
+ 'data',
41
+ 'dataProduct',
42
+ 'flow',
43
+ 'entity',
44
+ 'domain',
45
+ 'system',
46
+ 'custom',
47
+ ];
48
+
49
+ type DiagramNode = { type?: string; data?: Record<string, unknown> };
50
+ type ResourceDetails = { id?: unknown; version?: unknown; [key: string]: unknown };
51
+
52
+ /** The resource a node shows, as in its data: the fields shown, or the catalog entry's data (if it holds the entry) */
53
+ export const getNodeResourceDetails = (node: DiagramNode): ResourceDetails | undefined => {
54
+ const data = node.data ?? {};
55
+ const resource = RESOURCE_DATA_KEYS.map((key) => data[key]).find(Boolean) as
56
+ | (ResourceDetails & { data?: ResourceDetails })
57
+ | undefined;
58
+ return resource?.data ?? resource;
59
+ };
60
+
61
+ /** The catalog resource a node shows (its collection, id and version), if it shows one */
62
+ export const getNodeCatalogResource = (node: DiagramNode): { collection: string; id: string; version?: string } | undefined => {
63
+ const collection = NODE_COLLECTIONS[node.type ?? ''];
64
+ const details = getNodeResourceDetails(node);
65
+ if (!collection || typeof details?.id !== 'string') return undefined;
66
+ return { collection, id: details.id, ...(typeof details.version === 'string' && { version: details.version }) };
67
+ };
@@ -142,7 +142,7 @@ const wrapNodesInSystemGroup = (systemNodes: any[], system: any) => {
142
142
  draggable: false,
143
143
  selectable: false,
144
144
  data: {
145
- system: { name: system.data.name, version: system.data.version },
145
+ system: { id: system.data.id, name: system.data.name, version: system.data.version },
146
146
  // The system is the resource being viewed on its own diagram
147
147
  isFocused: true,
148
148
  },
@@ -0,0 +1,15 @@
1
+ import config from '@config';
2
+
3
+ /** The catalog's name: `title` in eventcatalog.config.js, or EventCatalog when it has none */
4
+ export const getCatalogTitle = (): string => config?.title?.trim() || 'EventCatalog';
5
+
6
+ /**
7
+ * A page's title, as browser tabs and link previews show it: the page's own title, then the
8
+ * catalog's (`Domains | Ordering | Acme Inc`), so tabs show the page first. A page without a title
9
+ * of its own (the homepage) is just the catalog's.
10
+ */
11
+ export const formatPageTitle = (title?: string): string => {
12
+ const catalogTitle = getCatalogTitle();
13
+ const pageTitle = title?.trim();
14
+ return pageTitle && pageTitle !== catalogTitle ? `${pageTitle} | ${catalogTitle}` : catalogTitle;
15
+ };