@eventcatalog/core 5.0.0-beta.29 → 5.0.0-beta.30

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.
@@ -126,6 +126,9 @@ function CatalogList({
126
126
  <div className="relative">
127
127
  <Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[rgb(var(--ec-icon-color))]" />
128
128
  <input
129
+ // The list shows when its tab is chosen: typing then searches it, rather than reaching the canvas's shortcuts
130
+ // (from the tab button, typing "Order Service" picked the Comment tool)
131
+ autoFocus
129
132
  value={search}
130
133
  onChange={(e) => setSearch(e.target.value)}
131
134
  placeholder="Search the catalog"
@@ -1,358 +1,21 @@
1
1
  ---
2
- import { buildUrl } from '@utils/url-builder';
3
- import config from '@config';
4
-
5
- import { getCollection } from 'astro:content';
6
2
  import VerticalSideBarLayout from '@layouts/VerticalSideBarLayout.astro';
7
- import { BookOpenText, Workflow, TableProperties, BookUser, Code2, FileJson, ArrowUpRight, ArrowRight, Plus } from 'lucide-react';
3
+ import CatalogDashboard from '@components/Homepage/CatalogDashboard.astro';
8
4
 
9
5
  const { showNestedSideBar = true } = Astro.props;
10
-
11
- // Lightweight data fetch: only raw collections, no hydration.
12
- const [allDomains, allServices, allEvents, allCommands, allQueries, allFlows] = await Promise.all([
13
- getCollection('domains'),
14
- getCollection('services'),
15
- getCollection('events'),
16
- getCollection('commands'),
17
- getCollection('queries'),
18
- getCollection('flows'),
19
- ]);
20
-
21
- const isCurrentVersion = (item: { data: { hidden?: boolean }; filePath?: string }) =>
22
- item.data.hidden !== true && !item.filePath?.includes('versioned');
23
-
24
- const domains = allDomains.filter(isCurrentVersion);
25
- const services = allServices.filter(isCurrentVersion);
26
- const events = allEvents.filter(isCurrentVersion);
27
- const commands = allCommands.filter(isCurrentVersion);
28
- const queries = allQueries.filter(isCurrentVersion);
29
- const messages = [...events, ...commands, ...queries];
30
- const flows = allFlows.filter(isCurrentVersion);
31
-
32
- const getDefaultUrl = (route: string, defaultValue: string) => {
33
- if (domains.length > 0) return buildUrl(`/${route}/domains/${domains[0].data.id}/${domains[0].data.version}`);
34
- if (services.length > 0) return buildUrl(`/${route}/services/${services[0].data.id}/${services[0].data.version}`);
35
- if (flows.length > 0) return buildUrl(`/${route}/flows/${flows[0].data.id}/${flows[0].data.version}`);
36
- return buildUrl(defaultValue);
37
- };
38
-
39
- interface StatTile {
40
- label: string;
41
- count: number;
42
- href: string;
43
- emptyHref: string;
44
- tone: string; // CSS color expression — drives accent rail + count gradient
45
- }
46
-
47
- const statTiles: StatTile[] = [
48
- {
49
- label: 'Domains',
50
- count: domains.length,
51
- href: buildUrl('/discover/domains'),
52
- emptyHref: 'https://www.eventcatalog.dev/docs/domains',
53
- tone: '250 204 21', // amber-400
54
- },
55
- {
56
- label: 'Services',
57
- count: services.length,
58
- href: buildUrl('/discover/services'),
59
- emptyHref: 'https://www.eventcatalog.dev/docs/services',
60
- tone: '244 114 182', // pink-400
61
- },
62
- {
63
- label: 'Messages',
64
- count: messages.length,
65
- href: buildUrl('/discover/events'),
66
- emptyHref: 'https://www.eventcatalog.dev/docs/messages',
67
- tone: '167 139 250', // violet-400
68
- },
69
- {
70
- label: 'Flows',
71
- count: flows.length,
72
- href: buildUrl('/discover/flows'),
73
- emptyHref: 'https://www.eventcatalog.dev/docs/flows',
74
- tone: '94 234 212', // teal-300
75
- },
76
- ];
77
-
78
- interface ExploreCard {
79
- title: string;
80
- description: string;
81
- icon: any;
82
- href: string;
83
- external?: boolean;
84
- /** When true, renders as the lead "feature" card with extra width and CTA styling. */
85
- feature?: boolean;
86
- tone: string; // RGB triple for the icon tile
87
- }
88
-
89
- const exploreCards: ExploreCard[] = [
90
- {
91
- title: 'Documentation',
92
- description: 'Browse every domain, service, message, and flow your teams have published.',
93
- icon: BookOpenText,
94
- href: getDefaultUrl('docs', 'domains'),
95
- feature: true,
96
- tone: '96 165 250', // blue-400
97
- },
98
- {
99
- title: 'Visualizer',
100
- description: 'See how services connect — message flows across your architecture.',
101
- icon: Workflow,
102
- href: getDefaultUrl('visualiser', 'domains'),
103
- tone: '167 139 250', // violet-400
104
- },
105
- {
106
- title: 'Discover',
107
- description: 'Search every event, service, agent, and team across the catalog.',
108
- icon: TableProperties,
109
- href: buildUrl('/discover/events'),
110
- tone: '94 234 212', // teal-300
111
- },
112
- {
113
- title: 'Schema Explorer',
114
- description: 'Track schema evolution, compare versions, and catch breaking changes.',
115
- icon: FileJson,
116
- href: buildUrl('/schemas/explorer'),
117
- tone: '250 204 21', // amber-400
118
- },
119
- {
120
- title: 'Team Directory',
121
- description: 'Find owners, on-call contacts, and the team behind every service.',
122
- icon: BookUser,
123
- href: buildUrl('/directory/users'),
124
- tone: '251 146 60', // orange-400
125
- },
126
- {
127
- title: 'API & SDK',
128
- description: 'Programmatic access — query and manage your catalog with a typed SDK.',
129
- icon: Code2,
130
- href: 'https://www.eventcatalog.dev/docs/sdk',
131
- external: true,
132
- tone: '129 140 248', // indigo-400
133
- },
134
- ];
135
6
  ---
136
7
 
137
8
  <VerticalSideBarLayout showNestedSideBar={showNestedSideBar}>
138
- <div class="ec-home min-h-screen">
139
- <!-- Hero -->
140
- <header class="ec-hero">
141
- <div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[rgb(var(--ec-accent))]">Catalog</div>
142
- <div class="mt-3 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
143
- <div>
144
- <h1 class="text-[28px] font-semibold leading-[1.1] tracking-tight text-[rgb(var(--ec-page-text))] md:text-[32px]">
145
- {config?.organizationName || 'EventCatalog'}
146
- </h1>
147
- <p class="mt-2 max-w-2xl text-[14px] leading-relaxed text-[rgb(var(--ec-page-text-muted))]">
148
- {config.tagline || 'Explore and understand your event-driven architecture.'}
149
- </p>
150
- </div>
151
- <div class="flex items-center gap-2">
152
- <a
153
- href={getDefaultUrl('docs', 'domains')}
154
- class="ec-btn-primary inline-flex items-center gap-1.5 rounded-md bg-[rgb(var(--ec-page-bg))] border border-[rgb(var(--ec-page-border))] px-3.5 py-2 text-[12.5px] font-semibold text-[rgb(var(--ec-page-text-muted))] transition-colors hover:border-[rgb(var(--ec-accent)/0.5)] hover:text-[rgb(var(--ec-accent))]"
155
- >
156
- Explore Catalog
157
- <ArrowRight className="h-3.5 w-3.5" />
158
- </a>
159
- </div>
160
- </div>
161
- </header>
162
-
163
- <div class="ec-divider"></div>
164
-
165
- <!-- Stat strip -->
166
- <section class="ec-stats">
167
- {
168
- statTiles.map((tile) => (
169
- <a
170
- href={tile.count > 0 ? tile.href : tile.emptyHref}
171
- target={tile.count === 0 ? '_blank' : undefined}
172
- rel={tile.count === 0 ? 'noreferrer' : undefined}
173
- class="ec-stat group"
174
- style={`--tone: ${tile.tone};`}
175
- >
176
- <div class="flex items-baseline justify-between gap-2">
177
- <span class="text-[11px] font-medium uppercase tracking-[0.14em] text-[rgb(var(--tone))]">{tile.label}</span>
178
- {tile.count > 0 ? (
179
- <ArrowUpRight className="h-3.5 w-3.5 text-[rgb(var(--ec-page-text-muted))] transition-transform duration-200 group-hover:-translate-y-0.5 group-hover:translate-x-0.5 group-hover:text-[rgb(var(--ec-accent))]" />
180
- ) : (
181
- <Plus className="h-3.5 w-3.5 text-[rgb(var(--ec-page-text-muted))] transition-colors group-hover:text-[rgb(var(--ec-accent))]" />
182
- )}
183
- </div>
184
- <div class="mt-3 flex items-baseline gap-2">
185
- <span class="font-mono text-[34px] font-semibold leading-none tracking-tight text-[rgb(var(--ec-page-text))]">
186
- {tile.count}
187
- </span>
188
- {tile.count === 0 && <span class="text-[12px] text-[rgb(var(--ec-page-text-muted))]">— add some</span>}
189
- </div>
190
- </a>
191
- ))
192
- }
193
- </section>
194
-
195
- <!-- Eyebrow + Explore grid -->
196
- <section class="mt-12">
197
- <div class="ec-section-eyebrow">
198
- <span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[rgb(var(--ec-accent))]">Explore</span>
199
- <span class="ec-section-rule"></span>
200
- <span class="font-mono text-[11px] text-[rgb(var(--ec-page-text-muted)/0.7)]">{exploreCards.length} sections</span>
201
- </div>
202
-
203
- <div class="ec-explore-grid mt-4">
204
- {
205
- exploreCards.map((card) => (
206
- <a
207
- href={card.href}
208
- target={card.external ? '_blank' : undefined}
209
- rel={card.external ? 'noreferrer' : undefined}
210
- class:list={['ec-card group', card.feature ? 'ec-card--feature' : '']}
211
- style={`--tone: ${card.tone};`}
212
- >
213
- <div class="flex items-start justify-between gap-3">
214
- <div class="ec-card-icon">
215
- <card.icon className="h-4 w-4" />
216
- </div>
217
- <ArrowUpRight className="h-3.5 w-3.5 text-[rgb(var(--ec-page-text-muted))] transition-transform duration-200 group-hover:-translate-y-0.5 group-hover:translate-x-0.5 group-hover:text-[rgb(var(--ec-accent))]" />
218
- </div>
219
- <h3 class="mt-4 text-[15px] font-semibold tracking-tight text-[rgb(var(--ec-page-text))]">{card.title}</h3>
220
- <p class="mt-1.5 text-[13px] leading-relaxed text-[rgb(var(--ec-page-text-muted))]">{card.description}</p>
221
- </a>
222
- ))
223
- }
224
- </div>
225
- </section>
9
+ <div class="ec-home">
10
+ <CatalogDashboard />
226
11
  </div>
227
12
  </VerticalSideBarLayout>
228
13
 
229
14
  <style is:global>
230
15
  .ec-home {
231
- /* Negate the layout wrapper's horizontal padding so the homepage controls its own. */
16
+ /* The layout pads its content: the homepage sets its own, wider spacing */
232
17
  margin-left: calc(var(--ec-app-content-padding-left, 5rem) * -1);
233
18
  margin-right: calc(var(--ec-app-content-padding-right, 5rem) * -1);
234
19
  padding: 2rem 2rem 0;
235
20
  }
236
- .ec-hero {
237
- padding-bottom: 1.5rem;
238
- }
239
- .ec-divider {
240
- height: 1px;
241
- background: linear-gradient(
242
- to right,
243
- transparent,
244
- rgb(var(--ec-page-border)) 12%,
245
- rgb(var(--ec-page-border)) 88%,
246
- transparent
247
- );
248
- margin: 0.25rem 0 1.5rem;
249
- }
250
- .ec-stats {
251
- display: grid;
252
- grid-template-columns: repeat(2, minmax(0, 1fr));
253
- gap: 0.75rem;
254
- }
255
- @media (min-width: 768px) {
256
- .ec-stats {
257
- grid-template-columns: repeat(4, minmax(0, 1fr));
258
- }
259
- }
260
- .ec-stat {
261
- position: relative;
262
- display: flex;
263
- flex-direction: column;
264
- overflow: hidden;
265
- border: 1px solid rgb(var(--ec-page-border));
266
- border-radius: 0.75rem;
267
- background:
268
- radial-gradient(80% 100% at 0% 0%, rgb(var(--tone) / 0.05), transparent 60%), rgb(var(--ec-card-bg, var(--ec-page-bg)));
269
- padding: 1rem 1.1rem 1.1rem;
270
- transition:
271
- border-color 200ms,
272
- transform 200ms,
273
- box-shadow 200ms;
274
- text-decoration: none;
275
- }
276
- .ec-stat:hover {
277
- border-color: rgb(var(--ec-accent) / 0.5);
278
- transform: translateY(-1px);
279
- }
280
-
281
- .ec-section-eyebrow {
282
- display: flex;
283
- align-items: center;
284
- gap: 0.75rem;
285
- }
286
- .ec-section-rule {
287
- flex: 1;
288
- height: 1px;
289
- background: linear-gradient(to right, rgb(var(--ec-accent) / 0.4), rgb(var(--ec-page-border)) 30%);
290
- }
291
-
292
- /* Explore grid: bordered cells with breathing room between them. */
293
- .ec-explore-grid {
294
- display: grid;
295
- grid-template-columns: repeat(1, minmax(0, 1fr));
296
- gap: 1rem;
297
- }
298
- @media (min-width: 640px) {
299
- .ec-explore-grid {
300
- grid-template-columns: repeat(2, minmax(0, 1fr));
301
- }
302
- }
303
- @media (min-width: 1024px) {
304
- .ec-explore-grid {
305
- grid-template-columns: repeat(3, minmax(0, 1fr));
306
- }
307
- }
308
-
309
- .ec-card {
310
- position: relative;
311
- display: flex;
312
- flex-direction: column;
313
- padding: 1.5rem 1.5rem 1.4rem;
314
- text-decoration: none;
315
- border: 1px solid rgb(var(--ec-page-border));
316
- border-radius: 0.875rem;
317
- background: rgb(var(--ec-card-bg, var(--ec-page-bg)));
318
- transition:
319
- background 200ms,
320
- border-color 200ms;
321
- min-height: 9.5rem;
322
- }
323
- .ec-card:hover {
324
- background: rgb(var(--ec-page-bg) / 0.4);
325
- border-color: rgb(var(--ec-accent) / 0.5);
326
- }
327
-
328
- .ec-card--feature {
329
- background:
330
- radial-gradient(60% 90% at 0% 0%, rgb(var(--tone) / 0.07), transparent 60%), rgb(var(--ec-card-bg, var(--ec-page-bg)));
331
- }
332
- .ec-card--feature:hover {
333
- background: radial-gradient(60% 90% at 0% 0%, rgb(var(--ec-accent) / 0.12), transparent 60%), rgb(var(--ec-page-bg) / 0.4);
334
- }
335
-
336
- .ec-card-icon {
337
- display: inline-flex;
338
- height: 2.25rem;
339
- width: 2.25rem;
340
- align-items: center;
341
- justify-content: center;
342
- border-radius: 0.5rem;
343
- background: rgb(var(--tone) / 0.12);
344
- color: rgb(var(--tone));
345
- transition:
346
- background 200ms,
347
- color 200ms;
348
- }
349
- .ec-card:hover .ec-card-icon {
350
- background: rgb(var(--ec-accent) / 0.2);
351
- color: rgb(var(--ec-accent));
352
- }
353
- .ec-card--feature .ec-card-icon {
354
- height: 2.5rem;
355
- width: 2.5rem;
356
- border-radius: 0.625rem;
357
- }
358
21
  </style>
@@ -30,7 +30,9 @@ if (existsSync(pathToUserDefinedLandingPage)) {
30
30
  import('@components/MDX/page-components'),
31
31
  import('@components/MDX/components'),
32
32
  ]);
33
- customHomepageComponents = { ...pageComponents, ...mdxComponents(props) };
33
+ const { default: CatalogDashboard } = await import('@components/Homepage/CatalogDashboard.astro');
34
+ // A custom homepage can show the catalog dashboard too: <CatalogDashboard /> from Astro.props.components
35
+ customHomepageComponents = { ...pageComponents, ...mdxComponents(props), CatalogDashboard };
34
36
 
35
37
  const rawContent = readFileSync(pathToUserDefinedLandingPage, 'utf-8');
36
38
  nodeGraphs = getMDXComponentsByName(rawContent, 'NodeGraph') || [];
@@ -87,6 +87,17 @@ const readCommit = (filePath: string, age: 'newest' | 'oldest'): Commit => {
87
87
  return parseCommit(match.groups.header);
88
88
  };
89
89
 
90
+ /**
91
+ * When a file was last committed, from the history read once for the whole repository: never runs git for one file,
92
+ * so it's cheap to ask for many (e.g. every resource on the homepage). Undefined without git, or for a file that isn't
93
+ * committed.
94
+ */
95
+ export function getLastCommitDate(filePath: string): Date | undefined {
96
+ if (!hasGit()) return undefined;
97
+ const commit = getNewestCommits()?.get(toRealPath(filePath));
98
+ return commit ? new Date(commit.timestamp) : undefined;
99
+ }
100
+
90
101
  export function getGitHistory(
91
102
  filePath: string,
92
103
  {
@@ -0,0 +1,143 @@
1
+ import path from 'node:path';
2
+ import { statSync } from 'node:fs';
3
+ import { getCollection } from 'astro:content';
4
+ import { getLastCommitDate } from '@utils/git';
5
+ import { buildUrl } from '@utils/url-builder';
6
+
7
+ /** The resources the dashboard shows, by collection */
8
+ export type DashboardCollection = 'domains' | 'services' | 'events' | 'commands' | 'queries' | 'flows';
9
+
10
+ type Owner = { id: string };
11
+
12
+ /** The fields the dashboard reads from a collection entry */
13
+ export type DashboardEntry = {
14
+ filePath?: string;
15
+ data: {
16
+ id: string;
17
+ name?: string;
18
+ version: string;
19
+ summary?: string;
20
+ hidden?: boolean;
21
+ owners?: Owner[];
22
+ schemaPath?: string;
23
+ };
24
+ };
25
+
26
+ export type DashboardResource = {
27
+ collection: DashboardCollection;
28
+ id: string;
29
+ version: string;
30
+ name: string;
31
+ summary?: string;
32
+ href: string;
33
+ owners: { id: string; name: string }[];
34
+ updatedAt?: number;
35
+ };
36
+
37
+ export type DashboardData = {
38
+ counts: Record<DashboardCollection | 'messages' | 'schemas' | 'teams' | 'users', number>;
39
+ recentlyUpdated: DashboardResource[];
40
+ };
41
+
42
+ const MESSAGE_COLLECTIONS = ['events', 'commands', 'queries'] as const;
43
+
44
+ // The latest version of a resource that isn't hidden (older versions live in a `versioned` folder)
45
+ const isCurrent = (entry: DashboardEntry) => entry.data.hidden !== true && !entry.filePath?.includes('versioned');
46
+
47
+ /**
48
+ * Everything the homepage dashboard shows, from the catalog's collections: counts, and what changed recently.
49
+ */
50
+ export const buildDashboard = ({
51
+ collections,
52
+ teams,
53
+ users,
54
+ getUpdatedAt,
55
+ }: {
56
+ collections: Record<DashboardCollection, DashboardEntry[]>;
57
+ teams: DashboardEntry[];
58
+ users: DashboardEntry[];
59
+ /** When a resource's file last changed (unix ms), if known */
60
+ getUpdatedAt: (entry: DashboardEntry) => number | undefined;
61
+ }): DashboardData => {
62
+ const ownerNames = new Map<string, string>();
63
+ for (const owner of [...teams, ...users]) ownerNames.set(owner.data.id, owner.data.name ?? owner.data.id);
64
+
65
+ const toResource = (collection: DashboardCollection, entry: DashboardEntry): DashboardResource => ({
66
+ collection,
67
+ id: entry.data.id,
68
+ version: entry.data.version,
69
+ name: entry.data.name ?? entry.data.id,
70
+ summary: entry.data.summary?.trim() || undefined,
71
+ href: buildUrl(`/docs/${collection}/${entry.data.id}/${entry.data.version}`),
72
+ owners: (entry.data.owners ?? []).map(({ id }) => ({ id, name: ownerNames.get(id) ?? id })),
73
+ updatedAt: getUpdatedAt(entry),
74
+ });
75
+
76
+ const current = Object.fromEntries(
77
+ Object.entries(collections).map(([collection, entries]) => [collection, entries.filter(isCurrent)])
78
+ ) as Record<DashboardCollection, DashboardEntry[]>;
79
+ const messages = MESSAGE_COLLECTIONS.flatMap((collection) => current[collection]);
80
+ const all = (Object.keys(current) as DashboardCollection[]).flatMap((collection) =>
81
+ current[collection].map((entry) => ({ entry, resource: toResource(collection, entry) }))
82
+ );
83
+
84
+ const recentlyUpdated = all
85
+ .map(({ resource }) => resource)
86
+ .filter((resource) => resource.updatedAt !== undefined)
87
+ .sort((a, b) => b.updatedAt! - a.updatedAt!)
88
+ .slice(0, 8);
89
+
90
+ const withSchema = messages.filter((entry) => entry.data.schemaPath).length;
91
+
92
+ return {
93
+ counts: {
94
+ domains: current.domains.length,
95
+ services: current.services.length,
96
+ events: current.events.length,
97
+ commands: current.commands.length,
98
+ queries: current.queries.length,
99
+ flows: current.flows.length,
100
+ messages: messages.length,
101
+ schemas: withSchema,
102
+ teams: teams.filter(isCurrent).length,
103
+ users: users.filter(isCurrent).length,
104
+ },
105
+ recentlyUpdated,
106
+ };
107
+ };
108
+
109
+ // When a resource's file last changed: its last commit, or (without git, or before it's committed) the file's date
110
+ const getUpdatedAt = (entry: DashboardEntry) => {
111
+ if (!entry.filePath) return undefined;
112
+ const file = path.resolve(process.env.PROJECT_DIR || process.cwd(), entry.filePath);
113
+ const committed = getLastCommitDate(file);
114
+ if (committed) return committed.getTime();
115
+ try {
116
+ return statSync(file).mtimeMs;
117
+ } catch {
118
+ return undefined;
119
+ }
120
+ };
121
+
122
+ /** The homepage dashboard for this catalog */
123
+ export const getDashboard = async (): Promise<DashboardData> => {
124
+ const [domains, services, events, commands, queries, flows, teams, users] = await Promise.all([
125
+ getCollection('domains'),
126
+ getCollection('services'),
127
+ getCollection('events'),
128
+ getCollection('commands'),
129
+ getCollection('queries'),
130
+ getCollection('flows'),
131
+ getCollection('teams'),
132
+ getCollection('users'),
133
+ ]);
134
+ return buildDashboard({
135
+ collections: { domains, services, events, commands, queries, flows } as unknown as Record<
136
+ DashboardCollection,
137
+ DashboardEntry[]
138
+ >,
139
+ teams: teams as unknown as DashboardEntry[],
140
+ users: users as unknown as DashboardEntry[],
141
+ getUpdatedAt,
142
+ });
143
+ };
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.29",
10
+ "version": "5.0.0-beta.30",
11
11
  "publishConfig": {
12
12
  "access": "public"
13
13
  },
@@ -142,8 +142,8 @@
142
142
  "yjs": "^13.6.33",
143
143
  "zod": "^4.3.6",
144
144
  "@eventcatalog/linter": "1.2.0-beta.4",
145
- "@eventcatalog/visualiser": "^5.0.0-beta.6",
146
- "@eventcatalog/sdk": "2.30.0-beta.1"
145
+ "@eventcatalog/sdk": "2.30.0-beta.1",
146
+ "@eventcatalog/visualiser": "^5.0.0-beta.7"
147
147
  },
148
148
  "devDependencies": {
149
149
  "@astrojs/check": "^0.9.10",