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

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 (64) hide show
  1. package/dist/analytics/analytics.cjs +1 -1
  2. package/dist/analytics/analytics.js +2 -2
  3. package/dist/analytics/log-build.cjs +1 -1
  4. package/dist/analytics/log-build.js +3 -3
  5. package/dist/{chunk-AYNZXUB5.js → chunk-3PKS2IIP.js} +4 -4
  6. package/dist/{chunk-HGRFXTFC.js → chunk-423S2KHN.js} +1 -1
  7. package/dist/{chunk-KIDISU6X.js → chunk-6OC46WQJ.js} +1 -1
  8. package/dist/{chunk-4J2ZM2HX.js → chunk-BH2F5WPN.js} +1 -1
  9. package/dist/{chunk-62EV4IJA.js → chunk-GYIVJXOJ.js} +1 -1
  10. package/dist/{chunk-XARPIXWF.js → chunk-NDJOAITI.js} +1 -1
  11. package/dist/constants.cjs +1 -1
  12. package/dist/constants.js +1 -1
  13. package/dist/eventcatalog.cjs +1 -1
  14. package/dist/eventcatalog.js +13 -13
  15. package/dist/federation/federate.js +4 -4
  16. package/dist/federation/source-provider.js +2 -2
  17. package/dist/generate.cjs +1 -1
  18. package/dist/generate.js +3 -3
  19. package/dist/utils/cli-logger.cjs +1 -1
  20. package/dist/utils/cli-logger.js +2 -2
  21. package/eventcatalog/src/components/Grids/utils.tsx +0 -23
  22. package/eventcatalog/src/components/MDX/CodeGroup/CodeGroup.tsx +6 -1
  23. package/eventcatalog/src/components/SchemaExplorer/SchemaDetailsPanel.tsx +12 -4
  24. package/eventcatalog/src/components/Tables/Discover/DiscoverTable.tsx +4 -62
  25. package/eventcatalog/src/components/Tables/Discover/columns.tsx +17 -150
  26. package/eventcatalog/src/components/Tables/Resources/ResourcesTable.tsx +425 -0
  27. package/eventcatalog/src/components/Tables/RowActionsMenu.tsx +157 -0
  28. package/eventcatalog/src/components/Tables/TablePagination.tsx +82 -0
  29. package/eventcatalog/src/features/ai/chat-api.ts +3 -3
  30. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +75 -74
  31. package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +0 -2
  32. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.spec.ts +52 -63
  33. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.ts +34 -46
  34. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/index.astro +11 -29
  35. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_ResourcesPage.astro +79 -0
  36. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_index.data.spec.ts +32 -14
  37. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_index.data.ts +63 -58
  38. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/index.astro +2 -62
  39. package/eventcatalog/src/pages/docs/services/[id]/[version]/resources/index.astro +13 -0
  40. package/eventcatalog/src/stores/sidebar-store/builders/domain.ts +24 -42
  41. package/eventcatalog/src/stores/sidebar-store/builders/flow.ts +2 -0
  42. package/eventcatalog/src/stores/sidebar-store/builders/message.ts +2 -0
  43. package/eventcatalog/src/stores/sidebar-store/builders/service.ts +23 -23
  44. package/eventcatalog/src/stores/sidebar-store/builders/shared.ts +13 -0
  45. package/eventcatalog/src/stores/sidebar-store/builders/system.ts +18 -27
  46. package/eventcatalog/src/stores/sidebar-store/state.ts +5 -0
  47. package/eventcatalog/src/styles/tailwind.css +2 -2
  48. package/eventcatalog/src/utils/collections/channels.ts +56 -1
  49. package/eventcatalog/src/utils/collections/flows.ts +50 -1
  50. package/eventcatalog/src/utils/collections/message-connections.ts +60 -0
  51. package/eventcatalog/src/utils/collections/message-triggers.ts +2 -1
  52. package/eventcatalog/src/utils/collections/resource-owners.ts +193 -0
  53. package/eventcatalog/src/utils/collections/resources.ts +310 -0
  54. package/eventcatalog/src/utils/collections/util.ts +31 -0
  55. package/package.json +6 -6
  56. package/eventcatalog/src/components/Grids/DomainGrid.tsx +0 -838
  57. package/eventcatalog/src/components/Grids/MessageGrid.tsx +0 -344
  58. package/eventcatalog/src/components/Grids/SystemGrid.tsx +0 -185
  59. package/eventcatalog/src/components/Grids/components.tsx +0 -170
  60. package/eventcatalog/src/components/Grids/message-link.ts +0 -38
  61. package/eventcatalog/src/components/Tables/SystemResources/SystemResourcesTable.tsx +0 -294
  62. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_resources.spec.ts +0 -65
  63. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_resources.ts +0 -46
  64. package/dist/{chunk-A2RZR3U4.js → chunk-6K7XZAYI.js} +3 -3
@@ -0,0 +1,193 @@
1
+ import { getCollection, type CollectionEntry, type CollectionKey } from 'astro:content';
2
+ import { getAdrs, getAdrsForResource } from './adrs';
3
+ import { getChannelConnections, getChannels } from './channels';
4
+ import { getCommands } from './commands';
5
+ import { getDomains } from './domains';
6
+ import { getEvents } from './events';
7
+ import { getFlows, getFlowStepResources, getFlowsWithServiceStep } from './flows';
8
+ import { getMessageConnections } from './message-connections';
9
+ import { getQueries } from './queries';
10
+ import { hasResources, type RelatedResources, type ResourceOwner, type ResourceOwnerCollection } from './resources';
11
+ import { getServices } from './services';
12
+ import { getSystems } from './systems';
13
+
14
+ // Loads the owners of Resources pages (domains, systems, services, flows, channels and messages)
15
+ // with the resources that point at them attached. Shared by the Resources pages, the sidebar's
16
+ // Resources links and the old /architecture redirects, so all three agree on which owners have
17
+ // a Resources page.
18
+
19
+ export const RESOURCE_OWNER_COLLECTIONS: ResourceOwnerCollection[] = [
20
+ 'domains',
21
+ 'systems',
22
+ 'services',
23
+ 'flows',
24
+ 'channels',
25
+ 'events',
26
+ 'commands',
27
+ 'queries',
28
+ ];
29
+
30
+ export const loadResourceOwners = async (type: ResourceOwnerCollection): Promise<ResourceOwner[]> => {
31
+ switch (type) {
32
+ case 'systems':
33
+ return getSystems();
34
+ case 'services':
35
+ return getServices();
36
+ case 'flows':
37
+ return getFlows();
38
+ case 'channels':
39
+ return getChannels();
40
+ case 'events':
41
+ return getEvents();
42
+ case 'commands':
43
+ return getCommands();
44
+ case 'queries':
45
+ return getQueries();
46
+ case 'domains':
47
+ return getDomains({ includeServicesInSubdomains: false });
48
+ }
49
+ };
50
+
51
+ const once = <T>(load: () => Promise<T>) => {
52
+ let loaded: Promise<T> | undefined;
53
+ return () => (loaded ??= load());
54
+ };
55
+
56
+ const isCurrentVersion = (entry: { data: { hidden?: boolean }; filePath?: string }) =>
57
+ entry.data.hidden !== true && !entry.filePath?.includes('versioned');
58
+
59
+ // Owners come from their own collection's loader, so an owner of a type is that collection's entry.
60
+ const asEntry = <C extends CollectionKey>(owner: ResourceOwner) => owner as unknown as CollectionEntry<C>;
61
+
62
+ /**
63
+ * What the related-resource lookups read. Each part loads the first time it is needed and is
64
+ * then shared, so working out every owner of a type, or every type, reads the catalog once.
65
+ */
66
+ export const createResourcesCatalog = () => {
67
+ const catalog = {
68
+ adrs: once(() => getAdrs({ getAllVersions: false })),
69
+ services: once(() => getServices()),
70
+ allFlows: once(() => getFlows()),
71
+ currentFlows: once(() => getFlows({ getAllVersions: false })),
72
+ rawChannels: once(() => getCollection('channels')),
73
+ rawMessages: once(async () => [
74
+ ...(await getCollection('events')),
75
+ ...(await getCollection('commands')),
76
+ ...(await getCollection('queries')),
77
+ ]),
78
+ messages: once(async () => [...(await getEvents()), ...(await getCommands()), ...(await getQueries())]),
79
+ // Raw entries keep the channel and trigger pointers on sends/receives that hydration drops.
80
+ // Like the sidebar, channels use the current services and agents and triggers use every version.
81
+ currentEndpoints: once(async () =>
82
+ [...(await getCollection('services')), ...(await getCollection('agents'))].filter(isCurrentVersion)
83
+ ),
84
+ receivers: once(async () => [
85
+ ...(await getCollection('services')),
86
+ ...(await getCollection('agents')),
87
+ ...(await getCollection('domains')),
88
+ ]),
89
+ };
90
+ return catalog;
91
+ };
92
+
93
+ export type ResourcesCatalog = ReturnType<typeof createResourcesCatalog>;
94
+
95
+ type Relate = (owner: ResourceOwner) => RelatedResources;
96
+
97
+ // Loads what a type's lookups need, then returns a function that works out one owner's related resources.
98
+ const relatedResourcesFor = async (type: ResourceOwnerCollection, catalog: ResourcesCatalog): Promise<Relate> => {
99
+ const adrs = await catalog.adrs();
100
+ const adrsFor = (owner: ResourceOwner) => getAdrsForResource(asEntry<ResourceOwnerCollection>(owner), adrs);
101
+
102
+ switch (type) {
103
+ case 'services': {
104
+ const [flows, services] = await Promise.all([catalog.currentFlows(), catalog.services()]);
105
+ return (owner) => ({
106
+ adrs: adrsFor(owner),
107
+ stepFlows: getFlowsWithServiceStep(asEntry<'services'>(owner), flows, services),
108
+ });
109
+ }
110
+ case 'flows': {
111
+ const [services, flows] = await Promise.all([catalog.services(), catalog.allFlows()]);
112
+ return (owner) => ({
113
+ adrs: adrsFor(owner),
114
+ stepResources: getFlowStepResources(asEntry<'flows'>(owner), { services, flows }),
115
+ });
116
+ }
117
+ case 'channels': {
118
+ const [endpoints, channels, messages] = await Promise.all([
119
+ catalog.currentEndpoints(),
120
+ catalog.rawChannels(),
121
+ catalog.rawMessages(),
122
+ ]);
123
+ return (owner) => {
124
+ const {
125
+ producers,
126
+ consumers,
127
+ messages: channelMessages,
128
+ } = getChannelConnections(asEntry<'channels'>(owner), { endpoints, channels, messages });
129
+ return { adrs: adrsFor(owner), channelProducers: producers, channelConsumers: consumers, channelMessages };
130
+ };
131
+ }
132
+ case 'events':
133
+ case 'commands':
134
+ case 'queries': {
135
+ const [receivers, messages, channels, flows] = await Promise.all([
136
+ catalog.receivers(),
137
+ catalog.messages(),
138
+ catalog.rawChannels(),
139
+ catalog.currentFlows(),
140
+ ]);
141
+ return (owner) => {
142
+ const connections = getMessageConnections(asEntry<'events' | 'commands' | 'queries'>(owner), {
143
+ receivers,
144
+ messages,
145
+ channels,
146
+ flows,
147
+ });
148
+ return {
149
+ adrs: adrsFor(owner),
150
+ messageChannels: connections.channels,
151
+ messageFlows: connections.flows,
152
+ messageTriggers: connections.triggers,
153
+ messageTriggeredBy: connections.triggeredBy,
154
+ };
155
+ };
156
+ }
157
+ default:
158
+ return (owner) => ({ adrs: adrsFor(owner) });
159
+ }
160
+ };
161
+
162
+ /** Every owner of the type with a Resources page, with its related resources attached. */
163
+ export const getOwnersWithResources = async (type: ResourceOwnerCollection, catalog = createResourcesCatalog()) => {
164
+ const [owners, relate] = await Promise.all([loadResourceOwners(type), relatedResourcesFor(type, catalog)]);
165
+ return owners.map((owner) => ({ ...owner, ...relate(owner) })).filter((owner) => hasResources(owner, type));
166
+ };
167
+
168
+ /** One owner with its related resources, or null when it does not exist or has no Resources page. Only that owner's lookups run. */
169
+ export const findOwnerWithResources = async (
170
+ type: ResourceOwnerCollection,
171
+ id: string,
172
+ version: string,
173
+ catalog = createResourcesCatalog()
174
+ ): Promise<ResourceOwner | null> => {
175
+ const owner = (await loadResourceOwners(type)).find(
176
+ (candidate) => candidate.data.id === id && candidate.data.version === version
177
+ );
178
+ if (!owner) return null;
179
+ const withRelated = { ...owner, ...(await relatedResourcesFor(type, catalog))(owner) };
180
+ return hasResources(withRelated, type) ? withRelated : null;
181
+ };
182
+
183
+ /** `collection:id:version` of every owner with a Resources page, for links to those pages. */
184
+ export const getResourcesPageKeys = async (catalog = createResourcesCatalog()) => {
185
+ const keys = new Set<string>();
186
+ // One type at a time; the shared catalog means each part is still read only once.
187
+ for (const type of RESOURCE_OWNER_COLLECTIONS) {
188
+ for (const owner of await getOwnersWithResources(type, catalog)) {
189
+ keys.add(`${type}:${owner.data.id}:${owner.data.version}`);
190
+ }
191
+ }
192
+ return keys;
193
+ };
@@ -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) => {
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "license": "BUSL-1.1",
9
9
  "type": "module",
10
- "version": "5.0.0-beta.2",
10
+ "version": "5.0.0-beta.3",
11
11
  "publishConfig": {
12
12
  "access": "public"
13
13
  },
@@ -54,7 +54,7 @@
54
54
  "@astrojs/rss": "^4.0.19",
55
55
  "@asyncapi/avro-schema-parser": "3.0.24",
56
56
  "@asyncapi/parser": "^3.6.0",
57
- "@asyncapi/react-component": "3.1.0",
57
+ "@asyncapi/react-component": "3.2.1",
58
58
  "@auth/core": "^0.41.3",
59
59
  "@eventcatalog/license": "^0.0.9",
60
60
  "@fontsource/inter": "^5.2.5",
@@ -111,8 +111,8 @@
111
111
  "parse5": "^7.3.0",
112
112
  "picocolors": "^1.1.1",
113
113
  "picomatch": "^4.0.4",
114
- "react": "^18.3.1",
115
- "react-dom": "^18.3.1",
114
+ "react": "^19.2.0",
115
+ "react-dom": "^19.2.0",
116
116
  "react-markdown": "^10.1.0",
117
117
  "react-syntax-highlighter": "^16.1.1",
118
118
  "recast": "^0.23.11",
@@ -135,8 +135,8 @@
135
135
  "uuid": "^11.1.1",
136
136
  "zod": "^4.3.6",
137
137
  "@eventcatalog/linter": "1.2.0-beta.0",
138
- "@eventcatalog/visualiser": "^5.0.0-beta.1",
139
- "@eventcatalog/sdk": "2.30.0-beta.0"
138
+ "@eventcatalog/sdk": "2.30.0-beta.0",
139
+ "@eventcatalog/visualiser": "^5.0.0-beta.2"
140
140
  },
141
141
  "devDependencies": {
142
142
  "@astrojs/check": "^0.9.10",