From 1d1111df7e1196d29c76888c3aeabee372a5192d Mon Sep 17 00:00:00 2001 From: David Boyne Date: Tue, 9 Dec 2025 16:59:31 +0000 Subject: [PATCH 01/71] EventCatalog V3 (#1864) * backup * backup * backup * backup * backup * backup * backup * backup * navigation only shows resources that are in teh catalog * navigation only shows resources that are in teh catalog * added favourites * updated the default landing page * removed unused code * turnoff pagefind * turnoff pagefind * turnoff pagefind * Create late-zoos-scream.md --- .changeset/late-zoos-scream.md | 5 + .changeset/pre.json | 8 + .gitignore | 2 + eventcatalog/public/logo_old.png | Bin 54304 -> 0 bytes .../src/components/CopyAsMarkdown.tsx | 20 +- .../src/components/DiscoverInsight.astro | 61 - .../src/components/FavoriteButton.tsx | 54 + .../src/components/Grids/DomainGrid.tsx | 748 ++++--- .../src/components/Grids/MessageGrid.tsx | 689 ++---- .../src/components/Grids/ServiceGrid.tsx | 540 ----- eventcatalog/src/components/Header.astro | 71 +- .../Lists/CustomSideBarSectionList.astro | 55 - .../src/components/Lists/ProtocolList.tsx | 74 - .../src/components/Lists/RepositoryList.astro | 37 - .../components/Lists/SpecificationsList.astro | 67 - .../src/components/Lists/VersionList.astro | 4 +- .../SchemaExplorer/SchemaDetailsPanel.tsx | 10 +- .../SchemaExplorer/SchemaPageViewer.tsx | 37 + .../src/components/Search/Search.astro | 76 +- .../src/components/Search/SearchModal.tsx | 1095 ++++----- .../components/SideBars/ChannelSideBar.astro | 204 -- .../SideBars/ContainerSideBar.astro | 183 -- .../components/SideBars/DomainSideBar.astro | 277 --- .../components/SideBars/EntitySideBar.astro | 139 -- .../src/components/SideBars/FlowSideBar.astro | 132 -- .../components/SideBars/MessageSideBar.astro | 251 --- .../components/SideBars/ServiceSideBar.astro | 298 --- .../components/CollapsibleGroup.tsx | 46 - .../components/MessageList.tsx | 78 - .../components/SpecificationList.tsx | 83 - .../SideNav/ListViewSideBar/index.tsx | 1250 ----------- .../SideNav/ListViewSideBar/types.ts | 91 - .../SideNav/ListViewSideBar/utils.ts | 201 -- .../SideNav/NestedSideBar/SearchBar.tsx | 298 +++ .../SideNav/NestedSideBar/__tests__/mocks.ts | 247 +++ .../__tests__/sidebar-builder.spec.ts | 1962 +++++++++++++++++ .../NestedSideBar/builders/container.ts | 66 + .../SideNav/NestedSideBar/builders/domain.ts | 101 + .../SideNav/NestedSideBar/builders/flow.ts | 29 + .../SideNav/NestedSideBar/builders/message.ts | 84 + .../SideNav/NestedSideBar/builders/service.ts | 147 ++ .../SideNav/NestedSideBar/builders/shared.ts | 146 ++ .../SideNav/NestedSideBar/index.tsx | 1073 +++++++++ .../SideNav/NestedSideBar/sidebar-builder.ts | 365 +++ .../SideNav/NestedSideBar/storage.ts | 90 + .../src/components/SideNav/SideNav.astro | 46 +- .../SideNav/TreeView/getTreeView.ts | 190 -- .../src/components/SideNav/TreeView/index.tsx | 94 - .../src/components/TreeView/index.tsx | 328 --- .../src/components/TreeView/styles.module.css | 264 --- .../src/components/TreeView/useSlots.ts | 95 - eventcatalog/src/content.config.ts | 2 + .../eventcatalog-chat/pages/chat/index.astro | 6 +- .../src/layouts/DirectoryLayout.astro | 4 +- eventcatalog/src/layouts/DiscoverLayout.astro | 6 +- .../src/layouts/VerticalSideBarLayout.astro | 147 +- .../src/layouts/VisualiserLayout.astro | 6 +- eventcatalog/src/pages/_index.astro | 640 +++++- .../[type]/[id]/[version]/_index.data.ts | 64 + .../[type]/[id]/[version]/index.astro | 29 + .../src/pages/architecture/[type]/index.astro | 14 - .../src/pages/architecture/architecture.astro | 110 - .../architecture/docs/[type]/index.astro | 14 - .../src/pages/directory/[type]/_index.data.ts | 8 +- .../pages/docs/[type]/[id]/[version].md.ts | 2 +- .../docs/[type]/[id]/[version]/_index.data.ts | 5 +- .../[id]/[version]/changelog/_index.data.ts | 6 +- .../[id]/[version]/changelog/index.astro | 6 +- .../docs/[type]/[id]/[version]/index.astro | 540 +++-- .../src/pages/docs/[type]/[id]/index.astro | 8 +- .../docs/[type]/[id]/language/_index.data.ts | 5 +- .../docs/[type]/[id]/language/index.astro | 30 +- .../src/pages/docs/teams/[id]/_index.data.ts | 4 +- .../src/pages/docs/users/[id]/_index.data.ts | 4 +- eventcatalog/src/pages/nav-index.json.ts | 30 + .../[type]/[id]/[version]/_index.data.ts | 77 + .../schemas/[type]/[id]/[version]/index.astro | 90 + .../pages/schemas/{ => explorer}/index.astro | 6 +- eventcatalog/src/pages/studio.astro | 6 +- .../pages/visualiser/[type]/[id]/index.astro | 4 +- eventcatalog/src/stores/favorites-store.ts | 83 + eventcatalog/src/stores/sidebar-store.ts | 8 + .../utils/__tests__/channels/channels.spec.ts | 2 +- .../__tests__/collections/file-diffs.spec.ts | 2 +- .../utils/__tests__/commands/commands.spec.ts | 2 +- .../__tests__/domains/node-graph.spec.ts | 164 +- .../utils/__tests__/entities/entities.spec.ts | 2 +- .../src/utils/__tests__/events/events.spec.ts | 2 +- .../utils/__tests__/messages/messages.spec.ts | 2 +- .../utils/__tests__/queries/queries.spec.ts | 2 +- .../utils/__tests__/services/services.spec.ts | 2 + .../src/utils/collections/changelogs.ts | 11 +- .../src/utils/{ => collections}/channels.ts | 112 +- .../src/utils/collections/commands.ts | 134 ++ .../src/utils/collections/containers.ts | 77 +- eventcatalog/src/utils/collections/domains.ts | 266 ++- .../src/utils/{ => collections}/entities.ts | 68 +- eventcatalog/src/utils/collections/events.ts | 136 ++ eventcatalog/src/utils/collections/flows.ts | 84 +- .../src/utils/{ => collections}/messages.ts | 17 +- .../src/utils/{ => collections}/queries.ts | 77 +- .../src/utils/collections/services.ts | 168 +- eventcatalog/src/utils/collections/teams.ts | 94 + eventcatalog/src/utils/collections/users.ts | 122 + eventcatalog/src/utils/collections/util.ts | 58 +- eventcatalog/src/utils/commands.ts | 112 - eventcatalog/src/utils/events.ts | 108 - eventcatalog/src/utils/feature.ts | 4 +- .../src/utils/{collections => }/file-diffs.ts | 2 +- eventcatalog/src/utils/generators/index.ts | 10 - .../utils/node-graphs/container-node-graph.ts | 2 + .../utils/node-graphs/domain-entity-map.ts | 22 +- .../src/utils/node-graphs/domains-canvas.ts | 24 +- .../utils/node-graphs/domains-node-graph.ts | 100 +- .../src/utils/node-graphs/flows-node-graph.ts | 42 +- .../utils/node-graphs/message-node-graph.ts | 85 +- .../utils/node-graphs/services-node-graph.ts | 40 +- .../utils/page-loaders/page-data-loader.ts | 8 +- eventcatalog/src/utils/teams.ts | 72 - eventcatalog/src/utils/users.ts | 72 - eventcatalog/tailwind.config.mjs | 14 + eventcatalog/tsconfig.json | 3 +- examples/default/domains/E-Commerce/index.mdx | 39 +- .../E-Commerce/subdomains/Orders/index.mdx | 12 - .../containers/inventory-db/index.mdx | 2 +- .../E-Commerce/subdomains/Payment/index.mdx | 2 + .../flows/CancelSubscription/index.mdx | 2 +- .../flows/SubscriptionRenewed/index.mdx | 2 +- .../subdomains/Subscriptions/index.mdx | 4 +- .../services/BillingService/index.mdx | 3 + examples/default/eventcatalog.config.js | 27 +- package.json | 9 +- pnpm-lock.yaml | 296 ++- src/eventcatalog.config.ts | 10 + src/eventcatalog.ts | 44 +- 135 files changed, 8927 insertions(+), 8324 deletions(-) create mode 100644 .changeset/late-zoos-scream.md create mode 100644 .changeset/pre.json delete mode 100644 eventcatalog/public/logo_old.png delete mode 100644 eventcatalog/src/components/DiscoverInsight.astro create mode 100644 eventcatalog/src/components/FavoriteButton.tsx delete mode 100644 eventcatalog/src/components/Grids/ServiceGrid.tsx delete mode 100644 eventcatalog/src/components/Lists/CustomSideBarSectionList.astro delete mode 100644 eventcatalog/src/components/Lists/ProtocolList.tsx delete mode 100644 eventcatalog/src/components/Lists/RepositoryList.astro delete mode 100644 eventcatalog/src/components/Lists/SpecificationsList.astro create mode 100644 eventcatalog/src/components/SchemaExplorer/SchemaPageViewer.tsx delete mode 100644 eventcatalog/src/components/SideBars/ChannelSideBar.astro delete mode 100644 eventcatalog/src/components/SideBars/ContainerSideBar.astro delete mode 100644 eventcatalog/src/components/SideBars/DomainSideBar.astro delete mode 100644 eventcatalog/src/components/SideBars/EntitySideBar.astro delete mode 100644 eventcatalog/src/components/SideBars/FlowSideBar.astro delete mode 100644 eventcatalog/src/components/SideBars/MessageSideBar.astro delete mode 100644 eventcatalog/src/components/SideBars/ServiceSideBar.astro delete mode 100644 eventcatalog/src/components/SideNav/ListViewSideBar/components/CollapsibleGroup.tsx delete mode 100644 eventcatalog/src/components/SideNav/ListViewSideBar/components/MessageList.tsx delete mode 100644 eventcatalog/src/components/SideNav/ListViewSideBar/components/SpecificationList.tsx delete mode 100644 eventcatalog/src/components/SideNav/ListViewSideBar/index.tsx delete mode 100644 eventcatalog/src/components/SideNav/ListViewSideBar/types.ts delete mode 100644 eventcatalog/src/components/SideNav/ListViewSideBar/utils.ts create mode 100644 eventcatalog/src/components/SideNav/NestedSideBar/SearchBar.tsx create mode 100644 eventcatalog/src/components/SideNav/NestedSideBar/__tests__/mocks.ts create mode 100644 eventcatalog/src/components/SideNav/NestedSideBar/__tests__/sidebar-builder.spec.ts create mode 100644 eventcatalog/src/components/SideNav/NestedSideBar/builders/container.ts create mode 100644 eventcatalog/src/components/SideNav/NestedSideBar/builders/domain.ts create mode 100644 eventcatalog/src/components/SideNav/NestedSideBar/builders/flow.ts create mode 100644 eventcatalog/src/components/SideNav/NestedSideBar/builders/message.ts create mode 100644 eventcatalog/src/components/SideNav/NestedSideBar/builders/service.ts create mode 100644 eventcatalog/src/components/SideNav/NestedSideBar/builders/shared.ts create mode 100644 eventcatalog/src/components/SideNav/NestedSideBar/index.tsx create mode 100644 eventcatalog/src/components/SideNav/NestedSideBar/sidebar-builder.ts create mode 100644 eventcatalog/src/components/SideNav/NestedSideBar/storage.ts delete mode 100644 eventcatalog/src/components/SideNav/TreeView/getTreeView.ts delete mode 100644 eventcatalog/src/components/SideNav/TreeView/index.tsx delete mode 100644 eventcatalog/src/components/TreeView/index.tsx delete mode 100644 eventcatalog/src/components/TreeView/styles.module.css delete mode 100644 eventcatalog/src/components/TreeView/useSlots.ts create mode 100644 eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.ts create mode 100644 eventcatalog/src/pages/architecture/[type]/[id]/[version]/index.astro delete mode 100644 eventcatalog/src/pages/architecture/[type]/index.astro delete mode 100644 eventcatalog/src/pages/architecture/architecture.astro delete mode 100644 eventcatalog/src/pages/architecture/docs/[type]/index.astro create mode 100644 eventcatalog/src/pages/nav-index.json.ts create mode 100644 eventcatalog/src/pages/schemas/[type]/[id]/[version]/_index.data.ts create mode 100644 eventcatalog/src/pages/schemas/[type]/[id]/[version]/index.astro rename eventcatalog/src/pages/schemas/{ => explorer}/index.astro (97%) create mode 100644 eventcatalog/src/stores/favorites-store.ts create mode 100644 eventcatalog/src/stores/sidebar-store.ts rename eventcatalog/src/utils/{ => collections}/channels.ts (57%) create mode 100644 eventcatalog/src/utils/collections/commands.ts rename eventcatalog/src/utils/{ => collections}/entities.ts (50%) create mode 100644 eventcatalog/src/utils/collections/events.ts rename eventcatalog/src/utils/{ => collections}/messages.ts (61%) rename eventcatalog/src/utils/{ => collections}/queries.ts (51%) create mode 100644 eventcatalog/src/utils/collections/teams.ts create mode 100644 eventcatalog/src/utils/collections/users.ts delete mode 100644 eventcatalog/src/utils/commands.ts delete mode 100644 eventcatalog/src/utils/events.ts rename eventcatalog/src/utils/{collections => }/file-diffs.ts (98%) delete mode 100644 eventcatalog/src/utils/generators/index.ts delete mode 100644 eventcatalog/src/utils/teams.ts delete mode 100644 eventcatalog/src/utils/users.ts diff --git a/.changeset/late-zoos-scream.md b/.changeset/late-zoos-scream.md new file mode 100644 index 000000000..0901a5f73 --- /dev/null +++ b/.changeset/late-zoos-scream.md @@ -0,0 +1,5 @@ +--- +"@eventcatalog/core": major +--- + +feat(core): eventcatalog-v3 release diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 000000000..f13cb45ab --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,8 @@ +{ + "mode": "pre", + "tag": "beta", + "initialVersions": { + "@eventcatalog/core": "2.65.1" + }, + "changesets": [] +} diff --git a/.gitignore b/.gitignore index 326a6a93e..16edf0808 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,8 @@ git-push.sh src/__tests__/example-catalog-dependencies/dependencies +**/__tests__/catalog/ + eventcatalog/public/ai examples/default/public/ai **/[...auth].ts diff --git a/eventcatalog/public/logo_old.png b/eventcatalog/public/logo_old.png deleted file mode 100644 index 9df4b958f86ae134163674c0edb1778efb5c015c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 54304 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 diff --git a/eventcatalog/src/components/CopyAsMarkdown.tsx b/eventcatalog/src/components/CopyAsMarkdown.tsx index 0360c3bf4..edda83a98 100644 --- a/eventcatalog/src/components/CopyAsMarkdown.tsx +++ b/eventcatalog/src/components/CopyAsMarkdown.tsx @@ -1,5 +1,5 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; -import { Copy, FileText, MessageCircleQuestion, ChevronDownIcon, ExternalLink, PenSquareIcon } from 'lucide-react'; +import { Copy, FileText, MessageCircleQuestion, ChevronDownIcon, ExternalLink, PenSquareIcon, RssIcon } from 'lucide-react'; import React, { useState, isValidElement } from 'react'; import type { Schema } from '@utils/collections/schemas'; import { buildUrl, toMarkdownUrl } from '@utils/url-builder'; @@ -48,12 +48,14 @@ export function CopyPageMenu({ chatEnabled = false, editUrl, markdownDownloadEnabled = false, + rssFeedEnabled = false, }: { schemas: Schema[]; chatQuery?: string; chatEnabled: boolean; editUrl: string; markdownDownloadEnabled: boolean; + rssFeedEnabled: boolean; }) { // Define available actions const availableActions = { @@ -62,6 +64,7 @@ export function CopyPageMenu({ copySchemas: schemas.length > 0, viewMarkdown: markdownDownloadEnabled, chat: chatEnabled, + rssFeed: rssFeedEnabled, }; // Check if any actions are available @@ -113,6 +116,13 @@ export function CopyPageMenu({ icon: MessageCircleQuestion, }; } + if (availableActions.rssFeed) { + return { + type: 'rssFeed', + text: 'RSS Feed', + icon: RssIcon, + }; + } return null; }; @@ -287,6 +297,14 @@ export function CopyPageMenu({ )} + {availableActions.rssFeed && ( + window.open(buildUrl(`/rss/all/rss.xml`), '_blank')} + > + + + )} {availableActions.chat && ( -
- -
- -
- 0 -
- -
- {label} - - { - isRSSEnabled && ( - - ) - } - - - diff --git a/eventcatalog/src/components/FavoriteButton.tsx b/eventcatalog/src/components/FavoriteButton.tsx new file mode 100644 index 000000000..ca78a9e1e --- /dev/null +++ b/eventcatalog/src/components/FavoriteButton.tsx @@ -0,0 +1,54 @@ +import React, { useState, useEffect } from 'react'; +import { StarIcon as StarIconOutline } from '@heroicons/react/24/outline'; +import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid'; +import { useStore } from '@nanostores/react'; +import { favoritesStore, toggleFavorite, type FavoriteItem } from '../stores/favorites-store'; + +interface FavoriteButtonProps { + nodeKey: string; + title: string; + badge?: string; + href?: string; + size?: 'sm' | 'md' | 'lg'; +} + +export default function FavoriteButton({ nodeKey, title, badge, href, size = 'md' }: FavoriteButtonProps) { + const favorites = useStore(favoritesStore); + const [isClient, setIsClient] = useState(false); + + useEffect(() => { + setIsClient(true); + }, []); + + const isFavorite = isClient && favorites.some((fav) => fav.nodeKey === nodeKey); + + const sizeClasses = { + sm: 'h-4 w-4', + md: 'h-6 w-6', + lg: 'h-8 w-8', + }; + + const handleToggleFavorite = () => { + const favoriteItem: FavoriteItem = { + nodeKey, + path: [], + title, + badge, + href, + }; + toggleFavorite(favoriteItem); + }; + + return ( + + ); +} diff --git a/eventcatalog/src/components/Grids/DomainGrid.tsx b/eventcatalog/src/components/Grids/DomainGrid.tsx index e71a18fc2..9f0e4ad4e 100644 --- a/eventcatalog/src/components/Grids/DomainGrid.tsx +++ b/eventcatalog/src/components/Grids/DomainGrid.tsx @@ -1,390 +1,414 @@ -import { useState, useMemo, useEffect } from 'react'; +import { memo, useMemo, useState } from 'react'; import { ServerIcon, - EnvelopeIcon, RectangleGroupIcon, - Squares2X2Icon, - QueueListIcon, + BoltIcon, + ChatBubbleLeftIcon, + MagnifyingGlassIcon, CircleStackIcon, + ChevronDownIcon, + ChevronUpIcon, + ArrowsPointingOutIcon, } from '@heroicons/react/24/outline'; -import { buildUrlWithParams, buildUrl } from '@utils/url-builder'; -import type { CollectionEntry } from 'astro:content'; -import { type CollectionMessageTypes } from '@types'; -import { getCollectionStyles } from './utils'; -import { SearchBar } from './components'; -import { BoxIcon } from 'lucide-react'; - -export interface ExtendedDomain extends CollectionEntry<'domains'> { - sends: CollectionEntry[]; - receives: CollectionEntry[]; - services: CollectionEntry<'services'>[]; - domains: CollectionEntry<'domains'>[]; -} +import { buildUrl } from '@utils/url-builder'; +import { BoxIcon, ArrowRight, ArrowLeft } from 'lucide-react'; + +// ============================================ +// Types +// ============================================ interface DomainGridProps { - domains: ExtendedDomain[]; - embeded: boolean; + domain: any; } -export default function DomainGrid({ domains, embeded }: DomainGridProps) { - const [searchQuery, setSearchQuery] = useState(''); - const [isMultiColumn, setIsMultiColumn] = useState(false); - - useEffect(() => { - if (typeof window !== 'undefined') { - const saved = localStorage.getItem('EventCatalog:ArchitectureColumnLayout'); - if (saved !== null) { - setIsMultiColumn(saved === 'multi'); - } - } - }, []); - - const toggleColumnLayout = () => { - const newValue = !isMultiColumn; - setIsMultiColumn(newValue); - if (typeof window !== 'undefined') { - localStorage.setItem('EventCatalog:ArchitectureColumnLayout', newValue ? 'multi' : 'single'); - } - }; - - const filteredDomains = useMemo(() => { - let result = [...domains]; - - // Filter by search query - if (searchQuery) { - const query = searchQuery.toLowerCase(); - result = result.filter( - (domain) => - domain.data.name?.toLowerCase().includes(query) || - domain.data.summary?.toLowerCase().includes(query) || - domain.data.services?.some((service: any) => service.data.name.toLowerCase().includes(query)) || - domain.sends?.some((message: any) => message.data.name.toLowerCase().includes(query)) || - domain.receives?.some((message: any) => message.data.name.toLowerCase().includes(query)) - ); - } - - // Sort by name by default - result.sort((a, b) => (a.data.name || a.data.id).localeCompare(b.data.name || b.data.id)); - - return result; - }, [domains, searchQuery]); +// ============================================ +// Helper functions +// ============================================ + +const getMessageIcon = (collection: string) => { + switch (collection) { + case 'events': + return { Icon: BoltIcon, color: 'orange' }; + case 'commands': + return { Icon: ChatBubbleLeftIcon, color: 'blue' }; + case 'queries': + return { Icon: MagnifyingGlassIcon, color: 'green' }; + default: + return { Icon: BoltIcon, color: 'gray' }; + } +}; + +// ============================================ +// Simple Sub-components +// ============================================ + +const EntityBadge = memo(({ entity }: { entity: any }) => { + const id = entity?.data?.id || entity?.id; + const name = entity?.data?.name || entity?.name || id; return ( -
- {/* Breadcrumb */} - - -
-
-
-

- Domains ({filteredDomains.length}) -

-

Browse and manage domains in your event-driven architecture

+ + + {name} + + ); +}); + +const MessageBadge = memo(({ message }: { message: any }) => { + const data = message?.data || message; + const collection = message?.collection || 'events'; + const { Icon, color } = getMessageIcon(collection); + const id = data?.id || message?.id; + const name = data?.name || data?.id || id; + const version = data?.version; + + return ( + + + {name} + + ); +}); + +const ContainerBadge = memo(({ container, type }: { container: any; type: 'reads' | 'writes' }) => { + const data = container?.data || container; + const id = data?.id || container?.id; + const name = data?.name || id; + const version = data?.version; + const colorClass = type === 'reads' ? 'orange' : 'purple'; + + return ( + + + {name} + + ); +}); + +const ServiceCard = memo(({ service }: { service: any }) => { + const data = service?.data || service; + if (!data?.id) return null; + + const receives = data.receives || []; + const sends = data.sends || []; + const readsFrom = data.readsFrom || []; + const writesTo = data.writesTo || []; + const hasMessages = receives.length > 0 || sends.length > 0; + const hasContainers = readsFrom.length > 0 || writesTo.length > 0; + + return ( +
+ {/* Service Header */} + + + {data.summary &&

{data.summary}

} + + {/* Message Flow Diagram */} + {hasMessages && ( +
+ {/* Receives (Inbound) */} +
+
+ + Inbound Messages + ({receives.length}) +
+ {receives.length > 0 ? ( +
+ {receives.slice(0, 4).map((msg: any, idx: number) => { + const msgId = msg?.data?.id || msg?.id; + return msgId ? : null; + })} + {receives.length > 4 &&

+{receives.length - 4} more

} +
+ ) : ( +

No incoming messages

+ )}
-
- - + {/* Service Icon (Center) */} +
+
+ +
-
-
-
- {filteredDomains.map((domain) => ( - s.data.id).join(','), - domainId: domain.data.id, - domainName: domain.data.name, - })} - className="group hover:bg-orange-100 border-2 border-orange-400/50 bg-yellow-50 rounded-lg shadow-sm hover:shadow-lg transition-all duration-200 overflow-hidden" - > -
-
-
- -

- {domain.data.name || domain.data.id} -

-
- - v{domain.data.version} - + {/* Sends (Outbound) */} +
+
+ + Outbound Messages + ({sends.length}) +
+ {sends.length > 0 ? ( +
+ {sends.slice(0, 4).map((msg: any, idx: number) => { + const msgId = msg?.data?.id || msg?.id; + return msgId ? : null; + })} + {sends.length > 4 &&

+{sends.length - 4} more

}
+ ) : ( +

No outgoing messages

+ )} +
+
+ )} -

- {domain.data.summary || No summary available} -

- -
-
- -
-

{domain.data.domains?.length || 0} Subdomains

-
-
-
- -
-

{domain.data.services?.length || 0} Services

-
-
-
- -
-

- {(domain.sends?.length || 0) + (domain.receives?.length || 0)} Messages -

-
-
- {domain.data.entities && domain.data.entities.length > 0 && ( -
- -
-

{domain.data.entities?.length} Entities

-
-
- )} + {/* Container Relationships */} + {hasContainers && ( +
+ {/* Reads From */} + {readsFrom.length > 0 && ( +
+
+ + Reads from +
+
+ {readsFrom.slice(0, 3).map((container: any, idx: number) => { + const containerId = container?.data?.id || container?.id; + return containerId ? : null; + })} + {readsFrom.length > 3 && +{readsFrom.length - 3} more}
+
+ )} -
- {/* Subdomains and there services */} - {domain.data.domains?.slice(0, 2).map((subdomain: any) => ( -
-
-
- -

- {subdomain.data.name || subdomain.data.id} (Subdomain) -

-
- v{subdomain.data.version} -
- -
-
- -
-

{subdomain.data.services?.length || 0} Services

-
-
-
- -
-

- {(subdomain.sends?.length || 0) + (subdomain.receives?.length || 0)} Messages -

-
-
-
-
- ))} - - {/* Services and their messages */} - {domain.data.services?.slice(0, 2).map((service: any) => ( -
-
-
- -

{service.data.name || service.data.id}

-
- v{service.data.version} -
- -
-
-
- {service.data.receives?.slice(0, 3).map((message: any) => { - const { Icon, color } = getCollectionStyles(message.collection); - return ( -
-
- -
- {message.id} -
- ); - })} - {service.data.receives && service.data.receives.length > 3 && ( -
-

+ {service.data.receives.length - 3} more

-
- )} - {!service.data.receives?.length && ( -
-

No messages received

-
- )} -
-
- -
-
-
-
- -
-

{service.data.name || service.data.id}

-

v{service.data.version}

-
-
-
-
-
- -
-
- {service.data.sends?.slice(0, 3).map((message: any) => { - const { Icon, color } = getCollectionStyles(message.collection); - return ( -
-
- -
- - {message.id} -
- ); - })} - {service.data.sends && service.data.sends.length > 3 && ( -
-

+ {service.data.sends.length - 3} more

-
- )} - {!service.data.sends?.length && ( -
-

No messages sent

-
- )} -
-
-
- - {/* Container lists at the bottom */} - {((service.data.readsFrom && service.data.readsFrom.length > 0) || - (service.data.writesTo && service.data.writesTo.length > 0)) && ( -
- {/* Reads From */} - {service.data.readsFrom && service.data.readsFrom.length > 0 && ( -
-
- -

Reads from

-
-
- {service.data.readsFrom.slice(0, 3).map((container: any) => ( - - - {container.id} - - ))} - {service.data.readsFrom.length > 3 && ( - - + {service.data.readsFrom.length - 3} more - - )} -
-
- )} - - {/* Writes To */} - {service.data.writesTo && service.data.writesTo.length > 0 && ( -
-
- -

Writes to

-
-
- {service.data.writesTo.slice(0, 3).map((container: any) => ( - - - {container.id} - - ))} - {service.data.writesTo.length > 3 && ( - - + {service.data.writesTo.length - 3} more - - )} -
-
- )} -
- )} -
- ))} - {domain.data.domains && domain.data.domains.length > 2 && ( -
-
-
- -

+{domain.data.domains.length - 2} more subdomains

-
-
-
- )} - {domain.data.services && domain.data.services.length > 2 && ( -
-
-
- -

+{domain.data.services.length - 2} more services

-
-
-
- )} + {/* Writes To */} + {writesTo.length > 0 && ( +
+
+ + Writes to +
+
+ {writesTo.slice(0, 3).map((container: any, idx: number) => { + const containerId = container?.data?.id || container?.id; + return containerId ? ( + + ) : null; + })} + {writesTo.length > 3 && +{writesTo.length - 3} more}
+ )} +
+ )} +
+ ); +}); + +const SubdomainSection = memo(({ subdomain }: { subdomain: any }) => { + const data = subdomain?.data || subdomain; + const [isCollapsed, setIsCollapsed] = useState(false); + + if (!data?.id) return null; + + const services = data.services || []; + const entities = data.entities || []; + + return ( +
+ {/* Subdomain Header */} + - {filteredDomains.length === 0 && ( -
-

No domains found matching your criteria

-
+ {!isCollapsed && ( + <> + {/* Subdomain Entities */} + {entities.length > 0 && ( +
+

Entities

+
+ {entities.map((entity: any) => { + const entityId = entity?.data?.id || entity?.id; + return entityId ? : null; + })} +
+
+ )} + + {/* Subdomain Services */} + {services.length > 0 && ( +
+

Services

+
+ {services.map((service: any) => { + const serviceId = service?.data?.id || service?.id; + // Ensure we pass the service down with its messages populated + return serviceId ? : null; + })} +
+
+ )} + + {entities.length === 0 && services.length === 0 && ( +

No entities or services in this subdomain

+ )} + )}
); +}); + +// ============================================ +// Main Component +// ============================================ + +export default function DomainGrid({ domain }: DomainGridProps) { + const data = domain?.data; + if (!data) return
No domain data
; + + const subdomains = data.domains || []; + const entities = data.entities || []; + const services = data.services || []; + + // Get services that are NOT in any subdomain + const subdomainServiceIds = useMemo( + () => + new Set( + subdomains.flatMap((sd: any) => { + const sdData = sd?.data || sd; + return (sdData?.services || []).map((s: any) => s?.data?.id || s?.id); + }) + ), + [subdomains] + ); + + const topLevelServices = useMemo( + () => + services.filter((s: any) => { + const sId = s?.data?.id || s?.id; + return sId && !subdomainServiceIds.has(sId); + }), + [services, subdomainServiceIds] + ); + + return ( +
+ {/* Domain Container - Yellow */} +
+ {/* Domain Header */} +
+
+ +

{data.name || data.id}

+ v{data.version} + Domain +
+ +
+ + {data.summary &&

{data.summary}

} + + {/* Domain Entities */} + {entities.length > 0 && ( +
+

Entities

+
+ {entities.map((entity: any) => { + const entityId = entity?.data?.id || entity?.id; + return entityId ? : null; + })} +
+
+ )} + + {/* Top-level Services (not in subdomains) */} + {topLevelServices.length > 0 && ( +
+

Services

+
+ {topLevelServices.map((service: any) => { + const serviceId = service?.data?.id || service?.id; + return serviceId ? : null; + })} +
+
+ )} + + {/* Subdomains - nested inside domain */} + {subdomains.length > 0 && ( +
+

Subdomains

+
+ {subdomains.map((subdomain: any) => { + const subdomainId = subdomain?.data?.id || subdomain?.id; + return subdomainId ? : null; + })} +
+
+ )} + + {/* Empty state */} + {entities.length === 0 && services.length === 0 && subdomains.length === 0 && ( +
+

This domain has no entities, services, or subdomains defined.

+
+ )} +
+
+ ); } diff --git a/eventcatalog/src/components/Grids/MessageGrid.tsx b/eventcatalog/src/components/Grids/MessageGrid.tsx index 97c77eab3..b1036f89b 100644 --- a/eventcatalog/src/components/Grids/MessageGrid.tsx +++ b/eventcatalog/src/components/Grids/MessageGrid.tsx @@ -1,197 +1,25 @@ -import { useState, useMemo, useEffect } from 'react'; -import { EnvelopeIcon, ChevronRightIcon, ServerIcon, CircleStackIcon } from '@heroicons/react/24/outline'; -import { RectangleGroupIcon } from '@heroicons/react/24/outline'; -import { buildUrl, buildUrlWithParams } from '@utils/url-builder'; +import { ServerIcon, CircleStackIcon } from '@heroicons/react/24/outline'; +import { buildUrl } from '@utils/url-builder'; import type { CollectionEntry } from 'astro:content'; -import type { CollectionMessageTypes } from '@types'; import { getCollectionStyles } from './utils'; -import { SearchBar, TypeFilters, Pagination } from './components'; -interface MessageGridProps { - messages: CollectionEntry[]; - containers?: CollectionEntry<'containers'>[]; - embeded: boolean; - isVisualiserEnabled: boolean; +interface MessageGridV2Props { + service: CollectionEntry<'services'>; + embeded?: boolean; } -interface GroupedMessages { - all?: CollectionEntry[]; - sends?: CollectionEntry[]; - receives?: CollectionEntry[]; -} - -export default function MessageGrid({ messages, embeded, containers, isVisualiserEnabled }: MessageGridProps) { - const [searchQuery, setSearchQuery] = useState(''); - const [urlParams, setUrlParams] = useState<{ - serviceId?: string; - serviceName?: string; - domainId?: string; - domainName?: string; - } | null>(null); - const [currentPage, setCurrentPage] = useState(1); - const [selectedTypes, setSelectedTypes] = useState([]); - const [producerConsumerFilter, setProducerConsumerFilter] = useState<'all' | 'no-producers' | 'no-consumers'>('all'); - const ITEMS_PER_PAGE = 15; - - // Effect to sync URL params with state - useEffect(() => { - const params = new URLSearchParams(window.location.search); - const serviceId = params.get('serviceId') || undefined; - const serviceName = params.get('serviceName') ? decodeURIComponent(params.get('serviceName')!) : undefined; - const domainId = params.get('domainId') || undefined; - const domainName = params.get('domainName') || undefined; - setUrlParams({ - serviceId, - serviceName, - domainId, - domainName, - }); - }, []); - - const filteredAndSortedMessages = useMemo(() => { - if (urlParams === null) return []; - - let result = [...messages]; - - // Filter by message type - if (selectedTypes.length > 0) { - result = result.filter((message) => selectedTypes.includes(message.collection)); - } - - // Apply producer/consumer filters - if (producerConsumerFilter === 'no-producers') { - result = result.filter((message) => !message.data.producers || message.data.producers.length === 0); - } else if (producerConsumerFilter === 'no-consumers') { - result = result.filter((message) => !message.data.consumers || message.data.consumers.length === 0); - } - - // Filter by service ID or name if present - if (urlParams.serviceId) { - result = result.filter( - (message) => - message.data.producers?.some( - (producer: any) => producer.id === urlParams.serviceId && !producer.id.includes('/versioned/') - ) || - message.data.consumers?.some( - (consumer: any) => consumer.id === urlParams.serviceId && !consumer.id.includes('/versioned/') - ) - ); - } - - // Filter by search query - if (searchQuery) { - const query = searchQuery.toLowerCase(); - result = result.filter( - (message) => - message.data.name?.toLowerCase().includes(query) || - message.data.summary?.toLowerCase().includes(query) || - message.data.producers?.some((producer: any) => producer.data.id?.toLowerCase().includes(query)) || - message.data.consumers?.some((consumer: any) => consumer.data.id?.toLowerCase().includes(query)) - ); - } - - // Sort by name by default - result.sort((a, b) => a.data.name.localeCompare(b.data.name)); - - return result; - }, [messages, searchQuery, urlParams, selectedTypes, producerConsumerFilter]); - - // Add totalPages calculation - const totalPages = useMemo(() => { - if (urlParams?.serviceId || urlParams?.domainId) return 1; - return Math.ceil(filteredAndSortedMessages.length / ITEMS_PER_PAGE); - }, [filteredAndSortedMessages.length, urlParams]); - - // Add paginatedMessages calculation - const paginatedMessages = useMemo(() => { - if (urlParams?.serviceId || urlParams?.domainId) { - return filteredAndSortedMessages; - } - - const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; - return filteredAndSortedMessages.slice(startIndex, startIndex + ITEMS_PER_PAGE); - }, [filteredAndSortedMessages, currentPage, urlParams]); - - // Reset pagination when search query or filters change - useEffect(() => { - setCurrentPage(1); - }, [searchQuery, selectedTypes]); - - // Group messages by sends/receives when a service is selected - const groupedMessages = useMemo(() => { - if (!urlParams?.serviceId) return { all: filteredAndSortedMessages }; - - const serviceIdentifier = urlParams.serviceId; - const sends = filteredAndSortedMessages.filter((message) => - message.data.producers?.some((producer: any) => producer.id === serviceIdentifier) - ); - const receives = filteredAndSortedMessages.filter((message) => - message.data.consumers?.some((consumer: any) => consumer.id === serviceIdentifier) - ); - - return { sends, receives }; - }, [filteredAndSortedMessages, urlParams]); - - // Get the containers that are referenced by the service - const serviceContainersReferenced = useMemo(() => { - if (!urlParams?.serviceId || !containers) return { writesTo: [], readsFrom: [] }; - return { - writesTo: containers.filter((container) => - container.data.servicesThatWriteToContainer?.some((service: any) => service.data.id === urlParams.serviceId) - ), - readsFrom: containers.filter((container) => - container.data.servicesThatReadFromContainer?.some((service: any) => service.data.id === urlParams.serviceId) - ), - }; - }, [containers, urlParams]); - - const renderTypeFilters = () => { - return ( -
-
- selectedTypes.includes(m.collection)).length} - /> -
- -
-
- - {producerConsumerFilter !== 'all' && ( - - )} -
-
-
- ); - }; +export default function MessageGridV2({ service, embeded = false }: MessageGridV2Props) { + const { sends = [], receives = [], writesTo = [], readsFrom = [] } = service.data; - const renderMessageGrid = (messages: CollectionEntry[]) => ( -
+ const renderMessageGrid = (messages: any[]) => ( + ); @@ -235,343 +40,181 @@ export default function MessageGrid({ messages, embeded, containers, isVisualise
); - const renderPaginationControls = () => { - if (totalPages <= 1 || urlParams?.serviceName || urlParams?.domainId) return null; - - return ( - - ); - }; - return ( -
- {/* Breadcrumb */} - - - {/* Title Section */} -
-
-
-
-

- {urlParams?.domainName ? `Messages in ${urlParams.serviceName}` : 'All Messages'} -

-
-

- {urlParams?.domainName - ? `Browse messages in the ${urlParams.serviceName} service` - : 'Browse and discover messages in your event-driven architecture'} -

-
- -
- -
+
+ {/* Service Title */} + -
- {/* Results count and top pagination */} -
- {renderTypeFilters()} - {renderPaginationControls()} -
-
+
+ {/* Left Column - Receives Messages & Reads From Containers */} +
+ {/* Receives Messages Section */} +
+
+

+ + Receives ({receives.length}) +

+
+ {receives.length > 0 ? ( + renderMessageGrid(receives) + ) : ( +
+

No messages

+
+ )} +
- {filteredAndSortedMessages.length > 0 && ( -
- {urlParams?.domainName && ( - <> -
- -
- {isVisualiserEnabled && ( - - View in visualizer - - )} + {/* Reads From Containers */} + {readsFrom.length > 0 && ( +
+
+

+ + Reads from ({readsFrom.length}) +

+
+ + ))}
- + {/* Arrow from Reads From to Service */} +
+
+
+
+
)} +
-
- {urlParams?.serviceName ? ( - <> - {/*
*/} - {/* Service Title */} -
- -

{urlParams.serviceName}

-
- {isVisualiserEnabled && ( - - View in visualizer - - )} - - Read documentation - -
-
-
- {/* Left Column - Receives Messages & Reads From Containers */} -
- {/* Receives Messages Section */} -
-
-

- - Receives ({groupedMessages.receives?.length || 0}) -

-
- {groupedMessages.receives && groupedMessages.receives.length > 0 ? ( - renderMessageGrid(groupedMessages.receives) - ) : ( -
-

No messages

-
- )} -
- - {/* Reads From Containers - Only show if containers exist */} - {serviceContainersReferenced.readsFrom && serviceContainersReferenced.readsFrom.length > 0 && ( -
-
-

- - Reads from ({serviceContainersReferenced.readsFrom.length}) -

-
-
- {serviceContainersReferenced.readsFrom.map((container: CollectionEntry<'containers'>) => ( - -
- -

- {container.data.name} -

-
- {container.data.summary && ( -

{container.data.summary}

- )} -
- ))} -
- {/* Arrow from Reads From to Service */} -
-
-
-
-
- )} -
- - {/* Arrow from Receives to Service */} -
-
-
-
- - {/* Service Information (Center) */} -
-
- -

{urlParams.serviceName}

- - {/* Quick Stats Grid */} -
-
-
{groupedMessages.receives?.length || 0}
-
Receives
-
-
-
{groupedMessages.sends?.length || 0}
-
Sends
-
- {serviceContainersReferenced.readsFrom && serviceContainersReferenced.readsFrom.length > 0 && ( -
-
- {serviceContainersReferenced.readsFrom.length} -
-
Reads from
-
- )} - {serviceContainersReferenced.writesTo && serviceContainersReferenced.writesTo.length > 0 && ( -
-
- {serviceContainersReferenced.writesTo.length} -
-
Writes to
-
- )} -
-
-
+ {/* Arrow from Receives to Service */} +
+
+
+
- {/* Arrow from Service to Sends */} -
-
-
-
+ {/* Service Information (Center) */} +
+
+ +

{service.data.name}

+ + {/* Quick Stats Grid */} +
+
+
{receives.length}
+
Receives
+
+
+
{sends.length}
+
Sends
+
+ {readsFrom.length > 0 && ( +
+
{readsFrom.length}
+
Reads from
+
+ )} + {writesTo.length > 0 && ( +
+
{writesTo.length}
+
Writes to
+
+ )} +
+
+
- {/* Right Column - Sends Messages & Writes To Containers */} -
- {/* Sends Messages Section */} -
-
-

- - Sends ({groupedMessages.sends?.length || 0}) -

-
- {groupedMessages.sends && groupedMessages.sends.length > 0 ? ( - renderMessageGrid(groupedMessages.sends) - ) : ( -
-

No messages

-
- )} -
+ {/* Arrow from Service to Sends */} +
+
+
+
- {/* Writes To Containers - Only show if containers exist */} - {serviceContainersReferenced.writesTo && serviceContainersReferenced.writesTo.length > 0 && ( -
- {/* Arrow from Service to Writes To */} -
-
-
-
-
-

- - Writes to ({serviceContainersReferenced.writesTo.length}) -

-
-
- {serviceContainersReferenced.writesTo.map((container: CollectionEntry<'containers'>) => ( - -
- -

- {container.data.name} -

-
- {container.data.summary && ( -

{container.data.summary}

- )} -
- ))} -
-
- )} -
-
- + {/* Right Column - Sends Messages & Writes To Containers */} +
+ {/* Sends Messages Section */} +
+
+

+ + Sends ({sends.length}) +

+
+ {sends.length > 0 ? ( + renderMessageGrid(sends) ) : ( - <> - {renderMessageGrid(paginatedMessages)} -
{renderPaginationControls()}
- +
+

No messages

+
)}
-
- )} - {filteredAndSortedMessages.length === 0 && ( -
-

No messages found matching your criteria

+ {/* Writes To Containers */} + {writesTo.length > 0 && ( +
+ {/* Arrow from Service to Writes To */} +
+
+
+
+
+

+ + Writes to ({writesTo.length}) +

+
+ +
+ )}
- )} +
); } diff --git a/eventcatalog/src/components/Grids/ServiceGrid.tsx b/eventcatalog/src/components/Grids/ServiceGrid.tsx deleted file mode 100644 index 85f3b649c..000000000 --- a/eventcatalog/src/components/Grids/ServiceGrid.tsx +++ /dev/null @@ -1,540 +0,0 @@ -import { useState, useMemo, useEffect, memo } from 'react'; -import { ServerIcon, ChevronRightIcon, Squares2X2Icon, QueueListIcon, CircleStackIcon } from '@heroicons/react/24/outline'; -import { RectangleGroupIcon } from '@heroicons/react/24/outline'; -import { buildUrl, buildUrlWithParams } from '@utils/url-builder'; -import type { CollectionEntry } from 'astro:content'; -import type { CollectionMessageTypes } from '@types'; -import { getCollectionStyles } from './utils'; -import { SearchBar, TypeFilters, Pagination } from './components'; -import type { ExtendedDomain } from './DomainGrid'; -import { BoxIcon } from 'lucide-react'; - -// Message component for reuse -const Message = memo(({ message, collection }: { message: any; collection: string }) => { - const { Icon, color } = getCollectionStyles(message.collection); - return ( - -
- -
- {message.data.name} -
- ); -}); - -// Messages Container component -const MessagesContainer = memo( - ({ messages, type, selectedTypes }: { messages: any[]; type: 'receives' | 'sends'; selectedTypes: string[] }) => { - const bgColor = type === 'receives' ? 'blue' : 'green'; - const MAX_MESSAGES_DISPLAYED = 4; - - const filteredMessages = messages?.filter( - (message: any) => selectedTypes.length === 0 || selectedTypes.includes(message.collection) - ); - - const messagesToShow = filteredMessages?.slice(0, MAX_MESSAGES_DISPLAYED); - const remainingMessagesCount = filteredMessages ? filteredMessages.length - MAX_MESSAGES_DISPLAYED : 0; - - return ( -
-
- {messagesToShow?.map((message: any) => ( - - ))} - {remainingMessagesCount > 0 && ( -
-

+ {remainingMessagesCount} more

-
- )} - {(!messages?.length || - (selectedTypes.length > 0 && !messages?.some((message: any) => selectedTypes.includes(message.collection)))) && ( -
-

- {selectedTypes.length > 0 - ? `Service does not ${type} ${selectedTypes.join(' or ')}` - : `Service does not ${type} any messages`} -

-
- )} -
-
- ); - } -); - -// Service Card component -const ServiceCard = memo(({ service, urlParams, selectedTypes }: { service: any; urlParams: any; selectedTypes: string[] }) => { - return ( - -
-
-
- -

- {service.data.name || service.data.id} (v{service.data.version}) -

-
-
- - {service.data.summary &&

{service.data.summary}

} - - {!urlParams?.serviceName && ( -
- - -
-
-
-
- -
-

{service.data.name || service.data.id}

-

v{service.data.version}

-
-
-
-
-
- - -
- )} - - {/* Container lists at the bottom */} - {((service.data.readsFrom && service.data.readsFrom.length > 0) || - (service.data.writesTo && service.data.writesTo.length > 0)) && ( -
- {/* Reads From */} - {service.data.readsFrom && service.data.readsFrom.length > 0 && ( -
-
- -

Reads from

-
-
- {service.data.readsFrom.slice(0, 3).map((container: any) => ( - - - {container.data.name} - - ))} - {service.data.readsFrom.length > 3 && ( - - + {service.data.readsFrom.length - 3} more - - )} -
-
- )} - - {/* Writes To */} - {service.data.writesTo && service.data.writesTo.length > 0 && ( -
-
- -

Writes to

-
-
- {service.data.writesTo.slice(0, 3).map((container: any) => ( - - - {container.data.name} - - ))} - {service.data.writesTo.length > 3 && ( - - + {service.data.writesTo.length - 3} more - - )} -
-
- )} -
- )} -
- - ); -}); - -// Domain Section component -const DomainSection = memo( - ({ - domain, - services, - urlParams, - selectedTypes, - isMultiColumn, - isVisualiserEnabled, - }: { - domain: any; - services: any[]; - urlParams: any; - selectedTypes: string[]; - isMultiColumn: boolean; - isVisualiserEnabled: boolean; - }) => { - const subdomains = domain.data.domains || []; - const allSubDomainServices = subdomains.map((subdomain: any) => subdomain.data.services || []).flat(); - - const servicesWithoutSubdomains = services.filter((service) => { - return !allSubDomainServices.some((s: any) => s.id === service.data.id); - }); - - return ( -
- {servicesWithoutSubdomains.length > 0 && ( -
- {servicesWithoutSubdomains.map((service) => ( - - ))} -
- )} - - {subdomains.map((subdomainRef: any) => { - const subdomain = domain.data.domains?.find((d: any) => d.data.id === subdomainRef.data.id); - if (!subdomain) return null; - - const subdomainServices = services.filter((service) => - subdomain.data.services?.some((s: any) => s.id === service.data.id) - ); - - if (subdomainServices.length === 0) return null; - - return ( -
-
-
- -

{subdomain.data.name} (Subdomain)

-
-
- {isVisualiserEnabled && ( - - View in visualizer - - )} - - Read documentation - -
-
- - {/* Entities */} - {subdomain.data.entities && subdomain.data.entities.length > 0 && ( -
-
- -

Entities

-
-
- {subdomain.data.entities.map((entity: any) => ( - - - {entity.id} - - ))} -
-
- )} - -
- {subdomainServices.map((service) => ( - - ))} -
-
- ); - })} -
- ); - } -); - -interface ServiceGridProps { - services: CollectionEntry<'services'>[]; - domains: ExtendedDomain[]; - embeded: boolean; - isVisualiserEnabled: boolean; -} - -// Main ServiceGrid component -export default function ServiceGrid({ services, domains, embeded, isVisualiserEnabled }: ServiceGridProps) { - const [searchQuery, setSearchQuery] = useState(''); - const [currentPage, setCurrentPage] = useState(1); - const [selectedTypes, setSelectedTypes] = useState([]); - const [isMultiColumn, setIsMultiColumn] = useState(false); - const ITEMS_PER_PAGE = 16; - const [urlParams, setUrlParams] = useState<{ - serviceIds?: string[]; - domainId?: string; - domainName?: string; - serviceName?: string; - } | null>(null); - - useEffect(() => { - const params = new URLSearchParams(window.location.search); - setUrlParams({ - serviceIds: params.get('serviceIds')?.split(',').filter(Boolean), - domainId: params.get('domainId') || undefined, - domainName: params.get('domainName') || undefined, - serviceName: params.get('serviceName') || undefined, - }); - }, []); - - useEffect(() => { - if (typeof window !== 'undefined') { - const saved = localStorage.getItem('EventCatalog:ServiceColumnLayout'); - if (saved !== null) { - setIsMultiColumn(saved === 'multi'); - } - } - }, []); - - const toggleColumnLayout = () => { - const newValue = !isMultiColumn; - setIsMultiColumn(newValue); - if (typeof window !== 'undefined') { - localStorage.setItem('EventCatalog:ServiceColumnLayout', newValue ? 'multi' : 'single'); - } - }; - - const filteredAndSortedServices = useMemo(() => { - if (urlParams === null) return []; - - let result = [...services]; - - if (urlParams.serviceIds?.length) { - result = result.filter( - (service) => urlParams.serviceIds?.includes(service.data.id) && !service.data.id.includes('/versioned/') - ); - } - - if (searchQuery) { - const query = searchQuery.toLowerCase(); - result = result.filter( - (service) => - service.data.name?.toLowerCase().includes(query) || - service.data.summary?.toLowerCase().includes(query) || - service.data.sends?.some((message: any) => message.data.name.toLowerCase().includes(query)) || - service.data.receives?.some((message: any) => message.data.name.toLowerCase().includes(query)) - ); - } - - if (selectedTypes.length > 0) { - result = result.filter((service) => { - const hasMatchingSends = service.data.sends?.some((message: any) => selectedTypes.includes(message.collection)); - const hasMatchingReceives = service.data.receives?.some((message: any) => selectedTypes.includes(message.collection)); - return hasMatchingSends || hasMatchingReceives; - }); - } - - result.sort((a, b) => (a.data.name || a.data.id).localeCompare(b.data.name || b.data.id)); - return result; - }, [services, searchQuery, urlParams, selectedTypes]); - - const paginatedServices = useMemo(() => { - if (urlParams?.domainId || urlParams?.serviceIds?.length) { - return filteredAndSortedServices; - } - const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; - return filteredAndSortedServices.slice(startIndex, startIndex + ITEMS_PER_PAGE); - }, [filteredAndSortedServices, currentPage, urlParams]); - - const totalPages = useMemo(() => { - if (urlParams?.domainId || urlParams?.serviceIds?.length) return 1; - return Math.ceil(filteredAndSortedServices.length / ITEMS_PER_PAGE); - }, [filteredAndSortedServices.length, urlParams]); - - useEffect(() => { - setCurrentPage(1); - }, [searchQuery, selectedTypes]); - - return ( -
- {/* Breadcrumb */} - - - {/* Title Section */} -
-
-
-
-

- {urlParams?.domainId ? `${urlParams.domainName} Architecture` : 'All Services'} -

-
-

- {urlParams?.domainId - ? `Browse services and messages in the ${urlParams.domainId} domain` - : 'Browse and discover services in your event-driven architecture'} -

-
- -
- - -
-
-
- -
- {/* Results count and pagination */} -
- -
- {urlParams?.domainId || urlParams?.serviceIds?.length ? ( - - Showing {filteredAndSortedServices.length} services in the {urlParams.domainId} domain - - ) : ( - - Showing {(currentPage - 1) * ITEMS_PER_PAGE + 1} to{' '} - {Math.min(currentPage * ITEMS_PER_PAGE, filteredAndSortedServices.length)} of {filteredAndSortedServices.length}{' '} - services - - )} -
- {!(urlParams?.domainId || urlParams?.serviceIds?.length) && ( - - )} -
-
- - {filteredAndSortedServices.length > 0 && ( -
- {urlParams?.domainName ? ( - domains - .filter((domain: ExtendedDomain) => domain.data.id === urlParams.domainId) - .map((domain: ExtendedDomain) => ( - - )) - ) : ( -
- {paginatedServices.map((service) => ( - - ))} -
- )} -
- )} - - {filteredAndSortedServices.length === 0 && ( -
-

- {selectedTypes.length > 0 - ? `No services found that ${selectedTypes.length > 1 ? 'handle' : 'handles'} ${selectedTypes.join(' or ')} messages` - : 'No services found matching your criteria'} -

-
- )} - - {/* Bottom pagination */} - {!(urlParams?.domainId || urlParams?.serviceIds?.length) && ( -
- -
- )} -
- ); -} diff --git a/eventcatalog/src/components/Header.astro b/eventcatalog/src/components/Header.astro index eb0519b4c..42e134668 100644 --- a/eventcatalog/src/components/Header.astro +++ b/eventcatalog/src/components/Header.astro @@ -23,20 +23,27 @@ const repositoryUrl = catalog?.repositoryUrl || 'https://github.com/event-catalo + + )} + + {/* Animation keyframes */} + + + ); +} diff --git a/eventcatalog/src/components/SideNav/NestedSideBar/sidebar-builder.ts b/eventcatalog/src/components/SideNav/NestedSideBar/sidebar-builder.ts new file mode 100644 index 000000000..62991cb18 --- /dev/null +++ b/eventcatalog/src/components/SideNav/NestedSideBar/sidebar-builder.ts @@ -0,0 +1,365 @@ +import { getContainers } from '@utils/collections/containers'; +import { getDomains } from '@utils/collections/domains'; +import { getServices } from '@utils/collections/services'; +import { getMessages, pluralizeMessageType } from '@utils/collections/messages'; +import { getOwner } from '@utils/collections/owners'; +import { getFlows } from '@utils/collections/flows'; +import { getUsers } from '@utils/collections/users'; +import { getTeams } from '@utils/collections/teams'; +import { buildUrl } from '@utils/url-builder'; +import type { NavigationData, NavNode, ChildRef } from './builders/shared'; +import { buildDomainNode } from './builders/domain'; +import { buildServiceNode } from './builders/service'; +import { buildMessageNode } from './builders/message'; +import { buildContainerNode } from './builders/container'; +import { buildFlowNode } from './builders/flow'; +import config from '@config'; +import { getDesigns } from '@utils/collections/designs'; + +export type { NavigationData, NavNode, ChildRef }; + +const CACHE_ENABLED = process.env.DISABLE_EVENTCATALOG_CACHE !== 'true'; +let memoryCache: NavigationData | null = null; + +/** + * Get the navigation data for the sidebar + */ +export const getNestedSideBarData = async (): Promise => { + if (memoryCache && CACHE_ENABLED) { + return memoryCache; + } + + const [domains, services, { events, commands, queries }, containers, flows, users, teams, designs] = await Promise.all([ + getDomains({ getAllVersions: false, includeServicesInSubdomains: false }), + getServices({ getAllVersions: false }), + getMessages({ getAllVersions: false }), + getContainers({ getAllVersions: false }), + getFlows({ getAllVersions: false }), + getUsers(), + getTeams(), + getDesigns(), + ]); + + // Calculate derived lists to avoid extra fetches + const allSubDomainIds = new Set(domains.flatMap((d) => (d.data.domains || []).map((sd: any) => sd.data.id))); + const rootDomains = domains.filter((d) => !allSubDomainIds.has(d.data.id)); + + const messages = [...events, ...commands, ...queries]; + + const context = { + services, + domains, + events, + commands, + queries, + flows, + containers, + }; + + // Process all domains with their owners first (async) + const domainsWithOwners = await Promise.all( + domains.map(async (domain) => { + const ownersInDomain = domain.data.owners || []; + const owners = await Promise.all(ownersInDomain.map((owner) => getOwner(owner))); + const filteredOwners = owners.filter((o) => o !== undefined) as Array>; + + return { + domain, + owners: filteredOwners, + }; + }) + ); + + // Services with owners + const servicesWithOwners = await Promise.all( + services.map(async (service) => { + const ownersInService = service.data.owners || []; + const owners = await Promise.all(ownersInService.map((owner) => getOwner(owner))); + const filteredOwners = owners.filter((o) => o !== undefined) as Array>; + return { service, owners: filteredOwners }; + }) + ); + + // Messages with owners + const messagesWithOwners = await Promise.all( + messages.map(async (message) => { + const ownersInMessage = message.data.owners || []; + const owners = await Promise.all(ownersInMessage.map((owner) => getOwner(owner))); + const filteredOwners = owners.filter((o) => o !== undefined) as Array>; + return { message, owners: filteredOwners }; + }) + ); + + const containerWithOwners = await Promise.all( + containers.map(async (container) => { + const ownersInContainer = container.data.owners || []; + const owners = await Promise.all(ownersInContainer.map((owner) => getOwner(owner))); + const filteredOwners = owners.filter((o) => o !== undefined) as Array>; + return { container, owners: filteredOwners }; + }) + ); + + const flowNodes = flows.reduce( + (acc, flow) => { + acc[`flow:${flow.data.id}:${flow.data.version}`] = buildFlowNode(flow); + return acc; + }, + {} as Record + ); + + const domainNodes = domainsWithOwners.reduce( + (acc, { domain, owners }) => { + acc[`domain:${domain.data.id}:${domain.data.version}`] = buildDomainNode(domain, owners, context); + if (domain.data.latestVersion === domain.data.version) { + acc[`domain:${domain.data.id}`] = buildDomainNode(domain, owners, context); + } + return acc; + }, + {} as Record + ); + + const serviceNodes = servicesWithOwners.reduce( + (acc, { service, owners }) => { + acc[`service:${service.data.id}:${service.data.version}`] = buildServiceNode(service, owners, context); + if (service.data.latestVersion === service.data.version) { + acc[`service:${service.data.id}`] = buildServiceNode(service, owners, context); + } + return acc; + }, + {} as Record + ); + + const messageNodes = messagesWithOwners.reduce( + (acc, { message, owners }) => { + const type = pluralizeMessageType(message as any); + + acc[`${type}:${message.data.id}:${message.data.version}`] = buildMessageNode(message, owners); + if (message.data.latestVersion === message.data.version) { + acc[`${type}:${message.data.id}`] = buildMessageNode(message, owners); + } + return acc; + }, + {} as Record + ); + + const containerNodes = containerWithOwners.reduce( + (acc, { container, owners }) => { + acc[`container:${container.data.id}:${container.data.version}`] = buildContainerNode(container, owners); + if (container.data.latestVersion === container.data.version) { + acc[`container:${container.data.id}`] = buildContainerNode(container, owners); + } + return acc; + }, + {} as Record + ); + + const designNodes = designs.reduce( + (acc, design) => { + acc[`design:${design.data.id}`] = { + type: 'item', + title: design.data.name, + badge: 'Design', + href: buildUrl(`/visualiser/designs/${design.data.id}`), + }; + return acc; + }, + {} as Record + ); + + const userNodes = users.reduce( + (acc, user) => { + acc[`user:${user.data.id}`] = { + type: 'item', + title: user.data.name, + href: buildUrl(`/docs/users/${user.data.id}`), + }; + return acc; + }, + {} as Record + ); + + const teamNodes = teams.reduce( + (acc, team) => { + acc[`team:${team.data.id}`] = { + type: 'item', + title: team.data.name, + href: buildUrl(`/docs/teams/${team.data.id}`), + }; + return acc; + }, + {} as Record + ); + + const rootDomainsNodes: Record = {}; + + if (rootDomains.length > 0) { + rootDomainsNodes['list:top-level-domains'] = { + type: 'group', + title: 'Domains', + icon: 'Boxes', + pages: rootDomains.map((domain) => `domain:${domain.data.id}:${domain.data.version}`), + }; + } + + const createLeaf = (items: any[], node: NavNode) => (items.length > 0 ? node : undefined); + + const domainsList = createLeaf(domains, { + type: 'item', + title: 'Domains', + icon: 'Boxes', + pages: domains.map((domain) => `domain:${domain.data.id}:${domain.data.version}`), + }); + + const servicesList = createLeaf(services, { + type: 'item', + title: 'Services', + icon: 'Server', + pages: services.map((service) => `service:${service.data.id}:${service.data.version}`), + }); + + const eventsList = createLeaf(events, { + type: 'group', + title: 'Events', + icon: 'Zap', + pages: events.map((event) => `event:${event.data.id}:${event.data.version}`), + }); + + const commandsList = createLeaf(commands, { + type: 'group', + title: 'Commands', + icon: 'Terminal', + pages: commands.map((command) => `command:${command.data.id}:${command.data.version}`), + }); + + const queriesList = createLeaf(queries, { + type: 'group', + title: 'Queries', + icon: 'Search', + pages: queries.map((query) => `query:${query.data.id}:${query.data.version}`), + }); + + const flowsList = createLeaf(flows, { + type: 'item', + title: 'Flows', + icon: 'Waypoints', + pages: flows.map((flow) => `flow:${flow.data.id}:${flow.data.version}`), + }); + + const containersList = createLeaf(containers, { + type: 'item', + title: 'Data Stores', + icon: 'Database', + pages: containers.map((container) => `container:${container.data.id}:${container.data.version}`), + }); + + const designsList = createLeaf(designs, { + type: 'item', + title: 'Designs', + icon: 'SquareMousePointer', + pages: designs.map((design) => `design:${design.data.id}`), + }); + + const teamsList = createLeaf(teams, { + type: 'group', + title: 'Teams', + icon: 'Users', + pages: teams.map((team) => `team:${team.data.id}`), + }); + + const usersList = createLeaf(users, { + type: 'group', + title: 'Users', + icon: 'User', + pages: users.map((user) => `user:${user.data.id}`), + }); + + const messagesChildren = ['list:events', 'list:commands', 'list:queries'].filter( + (key, index) => [eventsList, commandsList, queriesList][index] !== undefined + ); + + let messagesList; + if (messagesChildren.length > 0) { + messagesList = { + type: 'item', + title: 'Messages', + icon: 'Mail', + pages: messagesChildren, + }; + } + + const peopleChildren = ['list:teams', 'list:users'].filter((key, index) => [teamsList, usersList][index] !== undefined); + + let peopleList; + if (peopleChildren.length > 0) { + peopleList = { + type: 'item', + title: 'Teams & Users', + icon: 'Users', + pages: peopleChildren, + }; + } + + const allChildrenKeys = [ + 'list:domains', + 'list:services', + 'list:messages', + 'list:flows', + 'list:containers', + 'list:designs', + 'list:people', + ]; + const allChildrenNodes = [domainsList, servicesList, messagesList, flowsList, containersList, designsList, peopleList]; + + const validAllChildren = allChildrenKeys.filter((_, idx) => allChildrenNodes[idx] !== undefined); + + let allList; + if (validAllChildren.length > 0) { + allList = { + type: 'group', + title: 'Browse', + icon: 'Telescope', + pages: validAllChildren, + }; + } + + const allNodes: Record = { + ...(domainsList ? { 'list:domains': domainsList } : {}), + ...(servicesList ? { 'list:services': servicesList } : {}), + ...(eventsList ? { 'list:events': eventsList } : {}), + ...(commandsList ? { 'list:commands': commandsList } : {}), + ...(queriesList ? { 'list:queries': queriesList } : {}), + ...(messagesList ? { 'list:messages': messagesList as NavNode } : {}), + ...(flowsList ? { 'list:flows': flowsList } : {}), + ...(containersList ? { 'list:containers': containersList } : {}), + ...(designsList ? { 'list:designs': designsList } : {}), + ...(teamsList ? { 'list:teams': teamsList } : {}), + ...(usersList ? { 'list:users': usersList } : {}), + ...(peopleList ? { 'list:people': peopleList as NavNode } : {}), + ...(allList ? { 'list:all': allList as NavNode } : {}), + }; + + const allGeneratedNodes = { + ...rootDomainsNodes, + ...domainNodes, + ...serviceNodes, + ...messageNodes, + ...containerNodes, + ...flowNodes, + ...userNodes, + ...teamNodes, + ...designNodes, + ...allNodes, + }; + + // only filter if child is string + const rootNavigationConfig = config?.navigation?.pages || ['list:top-level-domains', 'list:all']; + + const navigationConfig = { + roots: rootNavigationConfig, + nodes: allGeneratedNodes, + }; + + memoryCache = navigationConfig; + + return navigationConfig; +}; diff --git a/eventcatalog/src/components/SideNav/NestedSideBar/storage.ts b/eventcatalog/src/components/SideNav/NestedSideBar/storage.ts new file mode 100644 index 000000000..9972a41e4 --- /dev/null +++ b/eventcatalog/src/components/SideNav/NestedSideBar/storage.ts @@ -0,0 +1,90 @@ +// ============================================ +// Local Storage Persistence +// ============================================ + +const STORAGE_KEY = 'eventcatalog-sidebar-nav'; +const COLLAPSED_SECTIONS_KEY = 'eventcatalog-sidebar-collapsed'; +const FAVORITES_KEY = 'eventcatalog-sidebar-favorites'; + +// ============================================ +// Types +// ============================================ + +export type PersistedState = { + path: string[]; // Array of node keys representing drill-down path + currentUrl: string; // The URL when this state was saved +}; + +export type FavoriteItem = { + nodeKey: string; // The key of the favorited node + path: string[]; // Path of keys to reach this node + title: string; // Display title + badge?: string; // Type badge (Domain, Service, etc.) + href?: string; // Direct link if it's a leaf item +}; + +// ============================================ +// Navigation State +// ============================================ + +export const saveState = (state: PersistedState): void => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch (e) { + console.warn('Failed to save sidebar state:', e); + } +}; + +export const loadState = (): PersistedState | null => { + try { + const stored = localStorage.getItem(STORAGE_KEY); + return stored ? JSON.parse(stored) : null; + } catch (e) { + console.warn('Failed to load sidebar state:', e); + return null; + } +}; + +// ============================================ +// Collapsed Sections +// ============================================ + +export const saveCollapsedSections = (sections: Set): void => { + try { + localStorage.setItem(COLLAPSED_SECTIONS_KEY, JSON.stringify([...sections])); + } catch (e) { + console.warn('Failed to save collapsed sections:', e); + } +}; + +export const loadCollapsedSections = (): Set => { + try { + const stored = localStorage.getItem(COLLAPSED_SECTIONS_KEY); + return stored ? new Set(JSON.parse(stored)) : new Set(); + } catch (e) { + console.warn('Failed to load collapsed sections:', e); + return new Set(); + } +}; + +// ============================================ +// Favorites +// ============================================ + +export const saveFavorites = (favorites: FavoriteItem[]): void => { + try { + localStorage.setItem(FAVORITES_KEY, JSON.stringify(favorites)); + } catch (e) { + console.warn('Failed to save favorites:', e); + } +}; + +export const loadFavorites = (): FavoriteItem[] => { + try { + const stored = localStorage.getItem(FAVORITES_KEY); + return stored ? JSON.parse(stored) : []; + } catch (e) { + console.warn('Failed to load favorites:', e); + return []; + } +}; diff --git a/eventcatalog/src/components/SideNav/SideNav.astro b/eventcatalog/src/components/SideNav/SideNav.astro index 427f8c361..525ca1a55 100644 --- a/eventcatalog/src/components/SideNav/SideNav.astro +++ b/eventcatalog/src/components/SideNav/SideNav.astro @@ -1,37 +1,27 @@ --- import type { HTMLAttributes } from 'astro/types'; -import config from '@config'; - -// FlatView -import { getResourcesForNavigation as getListViewResources } from './ListViewSideBar/utils'; - -// TreeView -import { SideNavTreeView } from './TreeView'; -import { getTreeView } from './TreeView/getTreeView'; - -import ListViewSideBar from './ListViewSideBar'; interface Props extends Omit, 'children'> {} +import { getNestedSideBarData } from './NestedSideBar/sidebar-builder'; +import NestedSideBar from './NestedSideBar'; +import { ClientRouter } from 'astro:transitions'; -const currentPath = Astro.url.pathname; - -let props; - -const SIDENAV_TYPE = config?.docs?.sidebar?.type ?? 'LIST_VIEW'; -const SHOW_ORPHANED_MESSAGES = config?.docs?.sidebar?.showOrphanedMessages ?? true; - -if (SIDENAV_TYPE === 'LIST_VIEW') { - props = await getListViewResources({ currentPath }); -} else if (SIDENAV_TYPE === 'TREE_VIEW') { - props = getTreeView({ projectDir: process.env.PROJECT_DIR!, currentPath }); -} +const props = await getNestedSideBarData(); ---
- { - SIDENAV_TYPE === 'LIST_VIEW' && ( - - ) - } - {SIDENAV_TYPE === 'TREE_VIEW' && } + +
+ + + + diff --git a/eventcatalog/src/components/SideNav/TreeView/getTreeView.ts b/eventcatalog/src/components/SideNav/TreeView/getTreeView.ts deleted file mode 100644 index b82868382..000000000 --- a/eventcatalog/src/components/SideNav/TreeView/getTreeView.ts +++ /dev/null @@ -1,190 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import os from 'node:os'; -import gm from 'gray-matter'; -import { globSync } from 'glob'; -import type { CollectionKey } from 'astro:content'; -import { buildUrl } from '@utils/url-builder'; - -export type TreeNode = { - id: string; - name: string; - version: string; - href?: string; - type: CollectionKey | null; - children: TreeNode[]; -}; - -/** - * Resource types that should be in the sidenav - */ -const RESOURCE_TYPES = ['domains', 'entities', 'services', 'events', 'commands', 'queries', 'flows', 'channels', 'containers']; -// const RESOURCE_TYPES = ['domains', 'services', 'events', 'commands', 'queries', 'flows', 'channels']; - -/** - * Check if the path has a RESOURCE_TYPE on path - */ -function canBeResource(dirPath: string) { - const parts = dirPath.split(path.sep); - for (let i = parts.length - 1; i >= 0; i--) { - if (RESOURCE_TYPES.includes(parts[i])) return true; - } - return false; -} - -function isNotVersioned(dirPath: string) { - const parts = dirPath.split(path.sep); - return parts.every((p) => p !== 'versioned'); -} - -function getResourceType(filePath: string): CollectionKey | null { - const parts = filePath.split(path.sep); - for (let i = parts.length - 1; i >= 0; i--) { - if (RESOURCE_TYPES.includes(parts[i])) return parts[i] as CollectionKey; - } - return null; -} - -function buildTreeOfDir(directory: string, parentNode: TreeNode, options: { ignore?: CollectionKey[] }) { - let node: TreeNode | null = null; - - const resourceType = getResourceType(directory); - - const markdownFiles = globSync(path.join(directory, '/*.mdx'), { windowsPathsNoEscape: os.platform() === 'win32' }); - const isResourceIgnored = options?.ignore && resourceType && options.ignore.includes(resourceType); - - if (markdownFiles.length > 0 && !isResourceIgnored) { - const resourceFilePath = markdownFiles.find((md) => md.endsWith('index.mdx')); - if (resourceFilePath) { - const resourceDef = gm.read(resourceFilePath); - node = { - id: resourceDef.data.id, - name: resourceDef.data.name, - type: resourceType, - version: resourceDef.data.version, - children: [], - }; - parentNode.children.push(node); - } - } - - const directories = fs.readdirSync(directory).filter((name) => { - const dirPath = path.join(directory, name); - return fs.statSync(dirPath).isDirectory() && isNotVersioned(dirPath) && canBeResource(dirPath); - }); - for (const dir of directories) { - buildTreeOfDir(path.join(directory, dir), node || parentNode, options); - } -} - -function forEachTreeNodeOf(node: TreeNode, ...callbacks: Array<(node: TreeNode) => void>) { - const next = node.children; - - callbacks.forEach((cb) => cb(node)); - - // Go to next level - next.forEach((n) => { - forEachTreeNodeOf(n, ...callbacks); - }); -} - -function addHrefToNode(basePathname: 'docs' | 'visualiser') { - return (node: TreeNode) => { - node.href = encodeURI( - buildUrl( - `/${basePathname}/${node.type}/${node.id}${node.type === 'teams' || node.type === 'users' ? '' : `/${node.version}`}` - ) - ); - }; -} - -function orderChildrenByName(parentNode: TreeNode) { - parentNode.children.sort((a, b) => a.name.localeCompare(b.name)); -} - -function groupChildrenByType(parentNode: TreeNode) { - if (parentNode.children.length === 0) return; // Only group if there are children - - const acc: Record = {}; - - // Flows and messages are collapsed by default - - parentNode.children.forEach((n) => { - if (n.type === null) return; // TODO: Just ignore or remove the type null??? - if (!(n.type in acc)) acc[n.type] = []; - acc[n.type].push(n); - }); - - // Collapse everything except domains - const AUTO_EXPANDED_TYPES = ['domains']; - - parentNode.children = Object.entries(acc) - // Order label nodes by RESOURCE_TYPES - .sort(([aType], [bType]) => RESOURCE_TYPES.indexOf(aType) - RESOURCE_TYPES.indexOf(bType)) - // Construct the label nodes - .map(([type, nodes]) => { - return { - id: `${parentNode.id}/${type}`, - name: type === 'containers' ? 'Data' : type, - type: type as CollectionKey, - version: '0', - children: nodes, - isExpanded: AUTO_EXPANDED_TYPES.includes(type), - isLabel: true, - }; - }); -} - -const treeViewCache = new Map(); - -export function getTreeView({ projectDir, currentPath }: { projectDir: string; currentPath: string }): TreeNode { - const basePathname = currentPath.split('/').find((p) => p === 'docs' || p === 'visualiser') || 'docs'; - - const cacheKey = `${projectDir}:${basePathname}`; - if (treeViewCache.has(cacheKey)) return treeViewCache.get(cacheKey)!; - - const rootNode: TreeNode = { - id: '/', - name: 'root', - type: null, - version: '0', - children: [], - }; - - buildTreeOfDir(projectDir, rootNode, { - ignore: basePathname === 'visualiser' ? ['teams', 'users', 'channels'] : undefined, - }); - - // prettier-ignore - forEachTreeNodeOf( - rootNode, - addHrefToNode(basePathname), - orderChildrenByName, - groupChildrenByType, - ); - - if (basePathname === 'visualiser') { - rootNode.children.unshift({ - id: '/bounded-context-map', - name: 'bounded context map', - type: 'bounded-context-map' as any, - version: '0', - isLabel: true, - children: [ - { - id: '/domain-map', - name: 'Domain map', - href: buildUrl('/visualiser/context-map'), - type: 'bounded-context-map' as any, - version: '', - children: [], - }, - ], - } as TreeNode); - } - - // Store in cache before returning - treeViewCache.set(cacheKey, rootNode); - - return rootNode; -} diff --git a/eventcatalog/src/components/SideNav/TreeView/index.tsx b/eventcatalog/src/components/SideNav/TreeView/index.tsx deleted file mode 100644 index fbe6a7f08..000000000 --- a/eventcatalog/src/components/SideNav/TreeView/index.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { gray } from 'tailwindcss/colors'; -import { TreeView } from '@components/TreeView'; -import { navigate } from 'astro:transitions/client'; -import type { TreeNode as RawTreeNode } from './getTreeView'; -import { getIconForCollection } from '@utils/collections/icons'; -import { useEffect, useState } from 'react'; - -type TreeNode = RawTreeNode & { isLabel?: true; isDefaultExpanded?: boolean; isExpanded?: boolean }; - -function isCurrentNode(node: TreeNode, currentPathname: string) { - return currentPathname === node.href; -} - -function TreeNode({ node }: { node: TreeNode }) { - const Icon = getIconForCollection(node.type ?? ''); - const [isCurrent, setIsCurrent] = useState(document.location.pathname === node.href); - - useEffect(() => { - const abortCtrl = new AbortController(); - // prettier-ignore - document.addEventListener( - 'astro:page-load', - () => setIsCurrent(document.location.pathname === node.href), - { signal: abortCtrl.signal }, - ); - return () => abortCtrl.abort(); - }, [document, node]); - - return ( - navigate(node.href!)} - > - {!node?.isLabel && ( - - - - )} - - {node.name} {node.isLabel ? `(${node.children.length})` : ''} - - {(node.children || []).length > 0 && ( - - {node.children!.map((childNode) => ( - - ))} - - )} - - ); -} - -export function SideNavTreeView({ tree }: { tree: TreeNode }) { - function bubbleUpExpanded(parentNode: TreeNode) { - if (isCurrentNode(parentNode, document.location.pathname)) return true; - return (parentNode.isDefaultExpanded = parentNode.children.some(bubbleUpExpanded)); - } - bubbleUpExpanded(tree); - - return ( - - ); -} diff --git a/eventcatalog/src/components/TreeView/index.tsx b/eventcatalog/src/components/TreeView/index.tsx deleted file mode 100644 index 570b724b1..000000000 --- a/eventcatalog/src/components/TreeView/index.tsx +++ /dev/null @@ -1,328 +0,0 @@ -import React, { useCallback, useEffect } from 'react'; -import classes from './styles.module.css'; -import { useSlots } from './useSlots'; -import { ChevronDownIcon, ChevronRightIcon } from 'lucide-react'; - -// ---------------------------------------------------------------------------- -// Context - -const RootContext = React.createContext<{ - // We cache the expanded state of tree items so we can preserve the state - // across remounts. This is necessary because we unmount tree items - // when their parent is collapsed. - expandedStateCache: React.RefObject | null>; -}>({ - expandedStateCache: { current: new Map() }, -}); - -const ItemContext = React.createContext<{ - level: number; - isExpanded: boolean; -}>({ - level: 1, - isExpanded: false, -}); - -// ---------------------------------------------------------------------------- -// TreeView - -export type TreeViewProps = { - 'aria-label'?: React.AriaAttributes['aria-label']; - 'aria-labelledby'?: React.AriaAttributes['aria-labelledby']; - children: React.ReactNode; - flat?: boolean; - truncate?: boolean; - style?: React.CSSProperties; -}; - -/* Size of toggle icon in pixels. */ -const TOGGLE_ICON_SIZE = 12; - -const Root: React.FC = ({ - 'aria-label': ariaLabel, - 'aria-labelledby': ariaLabelledby, - children, - flat, - truncate = true, - style, -}) => { - const containerRef = React.useRef(null); - const mouseDownRef = React.useRef(false); - - const onMouseDown = useCallback(() => { - mouseDownRef.current = true; - }, []); - - useEffect(() => { - function onMouseUp() { - mouseDownRef.current = false; - } - document.addEventListener('mouseup', onMouseUp); - return () => { - document.removeEventListener('mouseup', onMouseUp); - }; - }, []); - - const expandedStateCache = React.useRef | null>(null); - - if (expandedStateCache.current === null) { - expandedStateCache.current = new Map(); - } - - return ( - -
    - {children} -
-
- ); -}; - -Root.displayName = 'TreeView'; - -// ---------------------------------------------------------------------------- -// TreeView.Item - -export type TreeViewItemProps = { - id: string; - children: React.ReactNode; - current?: boolean; - defaultExpanded?: boolean; - onSelect?: (event: React.MouseEvent | React.KeyboardEvent) => void; -}; - -const Item = React.forwardRef( - ({ id: itemId, current: isCurrentItem = false, defaultExpanded, onSelect, children }, ref) => { - const [slots, rest] = useSlots(children, { - leadingVisual: LeadingVisual, - }); - const { expandedStateCache } = React.useContext(RootContext); - - const [isExpanded, setIsExpanded] = React.useState( - expandedStateCache.current?.get(itemId) ?? defaultExpanded ?? isCurrentItem - ); - const { level } = React.useContext(ItemContext); - const { hasSubTree, subTree, childrenWithoutSubTree } = useSubTree(rest); - const [isFocused, setIsFocused] = React.useState(false); - - // Set the expanded state and cache it - const setIsExpandedWithCache = React.useCallback( - (newIsExpanded: boolean) => { - setIsExpanded(newIsExpanded); - expandedStateCache.current?.set(itemId, newIsExpanded); - }, - [itemId, setIsExpanded, expandedStateCache] - ); - - // Expand or collapse the subtree - const toggle = React.useCallback( - (event?: React.MouseEvent | React.KeyboardEvent) => { - setIsExpandedWithCache(!isExpanded); - event?.stopPropagation(); - }, - [isExpanded, setIsExpandedWithCache] - ); - - const handleKeyDown = React.useCallback( - (event: React.KeyboardEvent) => { - switch (event.key) { - case 'Enter': - case ' ': - if (onSelect) { - onSelect(event); - } else { - toggle(event); - } - event.stopPropagation(); - break; - case 'ArrowRight': - // Ignore if modifier keys are pressed - if (event.altKey || event.metaKey) return; - event.preventDefault(); - event.stopPropagation(); - setIsExpandedWithCache(true); - break; - case 'ArrowLeft': - // Ignore if modifier keys are pressed - if (event.altKey || event.metaKey) return; - event.preventDefault(); - event.stopPropagation(); - setIsExpandedWithCache(false); - break; - } - }, - [onSelect, setIsExpandedWithCache, toggle] - ); - - return ( - -
  • } - tabIndex={0} - id={itemId} - role="treeitem" - aria-level={level} - aria-expanded={isExpanded} - aria-current={isCurrentItem ? 'true' : undefined} - aria-selected={isFocused ? 'true' : 'false'} - onKeyDown={handleKeyDown} - onFocus={(event) => { - // Scroll the first child into view when the item receives focus - event.currentTarget.firstElementChild?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); - - // Set the focused state - setIsFocused(true); - - // Prevent focus event from bubbling up to parent items - event.stopPropagation(); - }} - onBlur={() => setIsFocused(false)} - onClick={(event) => { - if (onSelect) { - onSelect(event); - // if has children open them too - if (hasSubTree) { - toggle(event); - } - } else { - toggle(event); - } - event.stopPropagation(); - }} - onAuxClick={(event) => { - if (onSelect && event.button === 1) { - onSelect(event); - } - event.stopPropagation(); - }} - > -
    -
    {/* */}
    - -
    - {slots.leadingVisual} - {childrenWithoutSubTree} -
    - {hasSubTree ? ( -
    { - if (onSelect) { - toggle(event); - } - }} - > - {isExpanded ? : } -
    - ) : null} -
    - {subTree} -
  • -
    - ); - } -); - -Item.displayName = 'TreeView.Item'; - -// ---------------------------------------------------------------------------- -// TreeView.SubTree - -export type TreeViewSubTreeProps = { - children?: React.ReactNode; -}; - -const SubTree: React.FC = ({ children }) => { - const { isExpanded } = React.useContext(ItemContext); - const ref = React.useRef(null); - - if (!isExpanded) { - return null; - } - - return ( -
      - {children} -
    - ); -}; - -SubTree.displayName = 'TreeView.SubTree'; - -function useSubTree(children: React.ReactNode) { - return React.useMemo(() => { - const subTree = React.Children.toArray(children).find((child) => React.isValidElement(child) && child.type === SubTree); - - const childrenWithoutSubTree = React.Children.toArray(children).filter( - (child) => !(React.isValidElement(child) && child.type === SubTree) - ); - - return { - subTree, - childrenWithoutSubTree, - hasSubTree: Boolean(subTree), - }; - }, [children]); -} - -// ---------------------------------------------------------------------------- -// TreeView.LeadingVisual - -export type TreeViewLeadingVisualProps = { - children: React.ReactNode | ((props: { isExpanded: boolean }) => React.ReactNode); -}; - -const LeadingVisual: React.FC = (props) => { - const { isExpanded } = React.useContext(ItemContext); - const children = typeof props.children === 'function' ? props.children({ isExpanded }) : props.children; - return ( -
    - {children} -
    - ); -}; - -LeadingVisual.displayName = 'TreeView.LeadingVisual'; - -// ---------------------------------------------------------------------------- -// Export - -export const TreeView = Object.assign(Root, { - Item, - SubTree, - LeadingVisual, -}); diff --git a/eventcatalog/src/components/TreeView/styles.module.css b/eventcatalog/src/components/TreeView/styles.module.css deleted file mode 100644 index e4b19dd34..000000000 --- a/eventcatalog/src/components/TreeView/styles.module.css +++ /dev/null @@ -1,264 +0,0 @@ -.TreeViewRootUlStyles { - padding: 0; - margin: 0; - list-style: none; - - /* - * WARNING: This is a performance optimization. - * - * We define styles for the tree items at the root level of the tree - * to avoid recomputing the styles for each item when the tree updates. - * We're sacrificing maintainability for performance because TreeView - * needs to be performant enough to handle large trees (thousands of items). - * - * This is intended to be a temporary solution until we can improve the - * performance of our styling patterns. - * - * Do NOT copy this pattern without understanding the tradeoffs. - */ - .TreeViewItem { - outline: none; - - &:focus-visible > div, - &.focus-visible > div { - box-shadow: var(--boxShadow-thick) /* var(--fgColor-accent) */ slategray; - - @media (forced-colors: active) { - outline: 2px solid HighlightText; - outline-offset: -2; - } - } - - &[data-has-leading-action] { - --has-leading-action: 1; - } - } - - .TreeViewItemContainer { - --level: 1; - --toggle-width: 1rem; - --min-item-height: 2rem; - - position: relative; - display: grid; - width: 100%; - font-size: var(--text-body-size-medium); - color: var(--fgColor-default); - cursor: pointer; - border-radius: var(--borderRadius-medium); - grid-template-columns: var(--spacer-width) var(--leading-action-width) 1fr var(--toggle-width); - grid-template-areas: 'spacer leadingAction content toggle'; - - --leading-action-width: calc(var(--has-leading-action, 0) * 1.5rem); - --spacer-width: calc(calc(var(--level) - 1) * (var(--toggle-width) / 2)); - - &:hover { - background-color: var(--control-transparent-bgColor-hover); - - @media (forced-colors: active) { - outline: 2px solid transparent; - outline-offset: -2px; - } - } - - @media (pointer: coarse) { - --toggle-width: 1.5rem; - --min-item-height: 2.75rem; - } - - &:has(.TreeViewItemSkeleton):hover { - cursor: default; - background-color: transparent; - - @media (forced-colors: active) { - outline: none; - } - } - } - - &:where([data-omit-spacer='true']) .TreeViewItemContainer { - grid-template-columns: 0 0 1fr 0; - } - - .TreeViewItem[aria-current='true'] > .TreeViewItemContainer { - background-color: var(--control-transparent-bgColor-selected); - - /* Current item indicator */ - /* stylelint-disable-next-line selector-max-specificity */ - &::after { - position: absolute; - top: calc(50% - var(--base-size-12)); - left: calc(-1 * var(--base-size-8)); - width: 0.25rem; - height: 1.5rem; - content: ''; - - /* - * Use fgColor accent for consistency across all themes. Using the "correct" variable, - * --bgColor-accent-emphasis, causes vrt failures for dark high contrast mode - */ - /* stylelint-disable-next-line primer/colors */ - background-color: var(--fgColor-accent); - border-radius: var(--borderRadius-medium); - - @media (forced-colors: active) { - background-color: HighlightText; - } - } - } - - .TreeViewItemToggle { - display: flex; - height: 100%; - - /* The toggle should appear vertically centered for single-line items, but remain at the top for items that wrap - across more lines. */ - /* stylelint-disable-next-line primer/spacing */ - padding-top: calc(var(--min-item-height) / 2 - var(--base-size-12) / 2); - color: var(--fgColor-muted); - grid-area: toggle; - justify-content: center; - align-items: flex-start; - } - - .TreeViewItemToggleHover:hover { - background-color: var(--control-transparent-bgColor-hover); - } - - .TreeViewItemToggleEnd { - border-top-left-radius: var(--borderRadius-medium); - border-bottom-left-radius: var(--borderRadius-medium); - } - - .TreeViewItemContent { - display: flex; - height: 100%; - padding: 0 var(--base-size-8); - - /* The dynamic top and bottom padding to maintain the minimum item height for single line items */ - /* stylelint-disable-next-line primer/spacing */ - padding-top: calc((var(--min-item-height) - var(--custom-line-height, 1.3rem)) / 2); - /* stylelint-disable-next-line primer/spacing */ - padding-bottom: calc((var(--min-item-height) - var(--custom-line-height, 1.3rem)) / 2); - line-height: var(--custom-line-height, var(--text-body-lineHeight-medium, 1.4285)); - grid-area: content; - gap: var(--stack-gap-condensed); - } - - .TreeViewItemContentText { - flex: 1 1 auto; - width: 0; - } - - &:where([data-truncate-text='true']) .TreeViewItemContentText { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - &:where([data-truncate-text='false']) .TreeViewItemContentText { - word-break: break-word; - } - - .TreeViewItemVisual { - display: flex; - - /* The visual icons should appear vertically centered for single-line items, but remain at the top for items that wrap - across more lines. */ - height: var(--custom-line-height, 1.3rem); - color: var(--fgColor-muted); - align-items: center; - } - - .TreeViewItemLeadingAction { - display: flex; - color: var(--fgColor-muted); - grid-area: leadingAction; - - & > button { - flex-shrink: 1; - } - } - - .TreeViewItemLevelLine { - width: 100%; - height: 100%; - - /* - * On devices without hover, the nesting indicator lines - * appear at all times. - */ - border-color: var(--borderColor-muted); - border-right: var(--borderWidth-thin) solid; - } - - /* - * On devices with :hover support, the nesting indicator lines - * fade in when the user mouses over the entire component, - * or when there's focus inside the component. This makes - * sure the component remains simple when not in use. - */ - @media (hover: hover) { - .TreeViewItemLevelLine { - border-color: transparent; - } - - &:hover .TreeViewItemLevelLine, - &:focus-within .TreeViewItemLevelLine { - border-color: var(--borderColor-muted); - } - } - - .TreeViewDirectoryIcon { - display: grid; - color: var(--treeViewItem-leadingVisual-iconColor-rest); - } - - .TreeViewVisuallyHidden { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - /* stylelint-disable-next-line primer/spacing */ - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border-width: 0; - } -} - -.TreeViewSkeletonItemContainerStyle { - display: flex; - align-items: center; - column-gap: 0.5rem; - height: 2rem; - - @media (pointer: coarse) { - height: 2.75rem; - } - - &:nth-of-type(5n + 1) { - --tree-item-loading-width: 67%; - } - - &:nth-of-type(5n + 2) { - --tree-item-loading-width: 47%; - } - - &:nth-of-type(5n + 3) { - --tree-item-loading-width: 73%; - } - - &:nth-of-type(5n + 4) { - --tree-item-loading-width: 64%; - } - - &:nth-of-type(5n + 5) { - --tree-item-loading-width: 50%; - } -} - -.TreeItemSkeletonTextStyles { - width: var(--tree-item-loading-width, 67%); -} diff --git a/eventcatalog/src/components/TreeView/useSlots.ts b/eventcatalog/src/components/TreeView/useSlots.ts deleted file mode 100644 index 7f00b0b94..000000000 --- a/eventcatalog/src/components/TreeView/useSlots.ts +++ /dev/null @@ -1,95 +0,0 @@ -import React from 'react'; -// import {warning} from '../utils/warning' - -// slot config allows 2 options: -// 1. Component to match, example: { leadingVisual: LeadingVisual } -type ComponentMatcher = React.ElementType; -// 2. Component to match + a test function, example: { blockDescription: [Description, props => props.variant === 'block'] } -type ComponentAndPropsMatcher = [ComponentMatcher, (props: Props) => boolean]; - -export type SlotConfig = Record; - -// We don't know what the props are yet, we set them later based on slot config -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type Props = any; - -type SlotElements = { - [Property in keyof Config]: SlotValue; -}; - -type SlotValue = Config[Property] extends React.ElementType // config option 1 - ? React.ReactElement, Config[Property]> - : Config[Property] extends readonly [ - infer ElementType extends React.ElementType, // config option 2, infer array[0] as component - // eslint-disable-next-line @typescript-eslint/no-unused-vars - infer _testFn, // even though we don't use testFn, we need to infer it to support types for slots.*.props - ] - ? React.ReactElement, ElementType> - : never; // useful for narrowing types, third option is not possible - -/** - * Extract components from `children` so we can render them in different places, - * allowing us to implement components with SSR-compatible slot APIs. - * Note: We can only extract direct children, not nested ones. - */ -export function useSlots( - children: React.ReactNode, - config: Config -): [Partial>, React.ReactNode[]] { - // Object mapping slot names to their elements - const slots: Partial> = mapValues(config, () => undefined); - - // Array of elements that are not slots - const rest: React.ReactNode[] = []; - - const keys = Object.keys(config) as Array; - const values = Object.values(config); - - // eslint-disable-next-line github/array-foreach - React.Children.forEach(children, (child) => { - if (!React.isValidElement(child)) { - rest.push(child); - return; - } - - const index = values.findIndex((value) => { - if (Array.isArray(value)) { - const [component, testFn] = value; - return child.type === component && testFn(child.props); - } else { - return child.type === value; - } - }); - - // If the child is not a slot, add it to the `rest` array - if (index === -1) { - rest.push(child); - return; - } - - const slotKey = keys[index]; - - // If slot is already filled, ignore duplicates - if (slots[slotKey]) { - // warning(true, `Found duplicate "${String(slotKey)}" slot. Only the first will be rendered.`) - return; - } - - // If the child is a slot, add it to the `slots` object - - slots[slotKey] = child as SlotValue; - }); - - return [slots, rest]; -} - -/** Map the values of an object */ -function mapValues, V>(obj: T, fn: (value: T[keyof T]) => V) { - return Object.keys(obj).reduce( - (result, key: keyof T) => { - result[key] = fn(obj[key]); - return result; - }, - {} as Record - ); -} diff --git a/eventcatalog/src/content.config.ts b/eventcatalog/src/content.config.ts index b7d6c55db..5f4a60183 100644 --- a/eventcatalog/src/content.config.ts +++ b/eventcatalog/src/content.config.ts @@ -390,6 +390,7 @@ const services = defineCollection({ entities: z.array(pointer).optional(), writesTo: z.array(pointer).optional(), readsFrom: z.array(pointer).optional(), + flows: z.array(pointer).optional(), detailsPanel: z .object({ domains: detailPanelPropertySchema.optional(), @@ -497,6 +498,7 @@ const domains = defineCollection({ services: z.array(pointer).optional(), domains: z.array(pointer).optional(), entities: z.array(pointer).optional(), + flows: z.array(pointer).optional(), detailsPanel: z .object({ parentDomains: detailPanelPropertySchema.optional(), diff --git a/eventcatalog/src/enterprise/eventcatalog-chat/pages/chat/index.astro b/eventcatalog/src/enterprise/eventcatalog-chat/pages/chat/index.astro index 62d156525..b5f5629de 100644 --- a/eventcatalog/src/enterprise/eventcatalog-chat/pages/chat/index.astro +++ b/eventcatalog/src/enterprise/eventcatalog-chat/pages/chat/index.astro @@ -5,10 +5,10 @@ import { getChatPromptsGroupedByCategory } from '@enterprise/eventcatalog-chat/u import config from '@config'; import { Code } from 'astro-expressive-code/components'; import { getDomains } from '@utils/collections/domains'; -import { getEvents } from '@utils/events'; -import { getCommands } from '@utils/commands'; +import { getEvents } from '@utils/collections/events'; +import { getCommands } from '@utils/collections/commands'; import { getServices } from '@utils/collections/services'; -import { getQueries } from '@utils/queries'; +import { getQueries } from '@utils/collections/queries'; const isEnabled = config.chat?.enabled || false; const chatConfig = config.chat || {}; diff --git a/eventcatalog/src/layouts/DirectoryLayout.astro b/eventcatalog/src/layouts/DirectoryLayout.astro index 96020c528..6bded655b 100644 --- a/eventcatalog/src/layouts/DirectoryLayout.astro +++ b/eventcatalog/src/layouts/DirectoryLayout.astro @@ -3,8 +3,8 @@ import { Table, type TData, type TCollectionTypes } from '@components/Tables/Tab import { buildUrl } from '@utils/url-builder'; import VerticalSideBarLayout from './VerticalSideBarLayout.astro'; import { User, Users } from 'lucide-react'; -import { getUsers } from '@utils/users'; -import { getTeams } from '@utils/teams'; +import { getUsers } from '@utils/collections/users'; +import { getTeams } from '@utils/collections/teams'; import config from '@config'; const users = await getUsers(); diff --git a/eventcatalog/src/layouts/DiscoverLayout.astro b/eventcatalog/src/layouts/DiscoverLayout.astro index d176398ba..f261e144c 100644 --- a/eventcatalog/src/layouts/DiscoverLayout.astro +++ b/eventcatalog/src/layouts/DiscoverLayout.astro @@ -2,13 +2,13 @@ import { Table, type TData, type TCollectionTypes } from '@components/Tables/Table'; import { QueueListIcon, RectangleGroupIcon, BoltIcon, ChatBubbleLeftIcon } from '@heroicons/react/24/outline'; import ServerIcon from '@heroicons/react/24/outline/ServerIcon'; -import { getCommands } from '@utils/commands'; +import { getCommands } from '@utils/collections/commands'; import { getDomains } from '@utils/collections/domains'; import { getFlows } from '@utils/collections/flows'; -import { getEvents } from '@utils/events'; +import { getEvents } from '@utils/collections/events'; import { getServices } from '@utils/collections/services'; import { buildUrl } from '@utils/url-builder'; -import { getQueries } from '@utils/queries'; +import { getQueries } from '@utils/collections/queries'; import { getContainers } from '@utils/collections/containers'; import { DatabaseIcon } from 'lucide-react'; import { MagnifyingGlassIcon } from '@heroicons/react/20/solid'; diff --git a/eventcatalog/src/layouts/VerticalSideBarLayout.astro b/eventcatalog/src/layouts/VerticalSideBarLayout.astro index 8bce236b9..12298c4a9 100644 --- a/eventcatalog/src/layouts/VerticalSideBarLayout.astro +++ b/eventcatalog/src/layouts/VerticalSideBarLayout.astro @@ -29,24 +29,39 @@ import '@fontsource/inter'; import '@fontsource/inter/400.css'; // Specify weight import '@fontsource/inter/700.css'; // Specify weight -import { getCommands } from '@utils/commands'; +import { ClientRouter } from 'astro:transitions'; + +import { getCommands } from '@utils/collections/commands'; import { getDomains } from '@utils/collections/domains'; -import { getEvents } from '@utils/events'; +import { getEvents } from '@utils/collections/events'; import { getServices } from '@utils/collections/services'; import { getFlows } from '@utils/collections/flows'; import { isCollectionVisibleInCatalog } from '@eventcatalog'; import { buildUrl } from '@utils/url-builder'; -import { getQueries } from '@utils/queries'; +import { getQueries } from '@utils/collections/queries'; import { hasLandingPageForDocs } from '@utils/pages'; -const events = await getEvents({ getAllVersions: false }); -const commands = await getCommands({ getAllVersions: false }); -const queries = await getQueries({ getAllVersions: false }); -const services = await getServices({ getAllVersions: false }); -const domains = await getDomains({ getAllVersions: false }); -const flows = await getFlows({ getAllVersions: false }); +const catalogHasDefaultLandingPageForDocs = await hasLandingPageForDocs(); const customDocs = await getCollection('customPages'); +let events: any[] = []; +let commands: any[] = []; +let queries: any[] = []; +let services: any[] = []; +let domains: any[] = []; +let flows: any[] = []; + +if (!catalogHasDefaultLandingPageForDocs) { + [events, commands, queries, services, domains, flows] = await Promise.all([ + getEvents({ getAllVersions: false, hydrateServices: false }), + getCommands({ getAllVersions: false, hydrateServices: false }), + getQueries({ getAllVersions: false, hydrateServices: false }), + getServices({ getAllVersions: false }), + getDomains({ getAllVersions: false }), + getFlows({ getAllVersions: false }), + ]); +} + import { isEventCatalogUpgradeEnabled, isVisualiserEnabled } from '@utils/feature'; // Try and load any custom styles if they exist @@ -56,8 +71,6 @@ try { const currentPath = Astro.url.pathname; -const catalogHasDefaultLandingPageForDocs = await hasLandingPageForDocs(); - const getDefaultUrl = (route: string, defaultValue: string) => { if (route === 'docs/custom') { return customDocs.length > 0 ? buildUrl(`/${route}/${customDocs[0].id.replace('docs', '')}`) : buildUrl(defaultValue); @@ -94,30 +107,16 @@ const navigationItems = [ id: '/', label: 'Home', icon: House, - href: buildUrl('/'), - current: currentPath === '/', - sidebar: false, - hidden: !!config.landingPage, - }, - { - id: '/docs', - label: 'Architecture Documentation', - icon: BookOpenText, - href: catalogHasDefaultLandingPageForDocs ? buildUrl('/docs') : getDefaultUrl('docs', '/docs'), + href: buildUrl(config.landingPage || '/'), current: - (currentPath.includes('/docs') && !currentPath.includes('/docs/custom')) || currentPath.includes('/architecture/docs/'), - sidebar: true, - }, - { - id: '/visualiser', - label: 'Visualiser', - icon: Workflow, - href: getDefaultUrl('visualiser', '/visualiser'), - current: currentPath.includes('/visualiser'), + currentPath === '/' || + (currentPath.includes('/docs') && !currentPath.includes('/docs/custom')) || + currentPath.includes('/architecture/') || + currentPath.includes('/visualiser') || + (currentPath.includes('/schemas') && !currentPath.includes('/schemas/explorer')), sidebar: true, - hidden: !isVisualiserEnabled(), + hidden: false, }, - { id: '/discover', label: 'Explore', @@ -130,8 +129,15 @@ const navigationItems = [ id: '/schemas', label: 'Schema Explorer', icon: FileJson, - href: buildUrl('/schemas'), - current: currentPath.includes('/schemas'), + sidebar: true, + hidden: true, + }, + { + id: '/schemas/explorer', + label: 'Schema Explorer', + icon: FileJson, + href: buildUrl('/schemas/explorer'), + current: currentPath.includes('/schemas/explorer'), sidebar: false, }, { @@ -148,7 +154,7 @@ const navigationItems = [ icon: BookUser, href: buildUrl('/architecture/domains'), current: currentPath.includes('/architecture/'), - sidebar: false, + sidebar: true, hidden: true, }, ].filter((item) => { @@ -259,7 +265,7 @@ const canPageBeEmbedded = process.env.ENABLE_EMBED === 'true';
    @@ -389,6 +395,7 @@ const canPageBeEmbedded = process.env.ENABLE_EMBED === 'true';
    + diff --git a/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.ts b/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.ts new file mode 100644 index 000000000..7766edb51 --- /dev/null +++ b/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.ts @@ -0,0 +1,64 @@ +import { isSSR } from '@utils/feature'; +import { HybridPage } from '@utils/page-loaders/hybrid-page'; +import type { PageTypes } from '@types'; +import { pageDataLoader } from '@utils/page-loaders/page-data-loader'; + +/** + * Documentation page class for all collection types with versioning + */ +export class Page extends HybridPage { + static async getStaticPaths() { + if (isSSR()) { + return []; + } + + const itemTypes: PageTypes[] = ['services', 'domains']; + const allItems = await Promise.all(itemTypes.map((type) => pageDataLoader[type]())); + + return allItems.flatMap((items, index) => + items.map((item) => ({ + params: { + type: itemTypes[index], + id: item.data.id, + version: item.data.version, + }, + props: { + type: itemTypes[index], + ...item, + // Not everything needs the body of the page itself. + body: undefined, + }, + })) + ); + } + + protected static async fetchData(params: any) { + const { type, id, version } = params; + + if (!type || !id || !version) { + return null; + } + + // Get all items of the specified type + const items = await pageDataLoader[type as PageTypes](); + + // Find the specific item by id and version + const item = items.find((i) => i.data.id === id && i.data.version === version); + + if (!item) { + return null; + } + + return { + type, + ...item, + }; + } + + protected static createNotFoundResponse(): Response { + return new Response(null, { + status: 404, + statusText: 'Documentation not found', + }); + } +} diff --git a/eventcatalog/src/pages/architecture/[type]/[id]/[version]/index.astro b/eventcatalog/src/pages/architecture/[type]/[id]/[version]/index.astro new file mode 100644 index 000000000..1d6ac92de --- /dev/null +++ b/eventcatalog/src/pages/architecture/[type]/[id]/[version]/index.astro @@ -0,0 +1,29 @@ +--- +import DomainGrid from '@components/Grids/DomainGrid'; +import MessageGrid from '@components/Grids/MessageGrid'; + +import VerticalSideBarLayout from '@layouts/VerticalSideBarLayout.astro'; +import { Page } from './_index.data'; + +export const prerender = Page.prerender; +export const getStaticPaths = Page.getStaticPaths; + +// Get data +const props = await Page.getData(Astro); +let domain = props; + +const pageTitle = `${props.type} | ${props.data.name}`.replace(/^\w/, (c) => c.toUpperCase()); + +const type = props.type; +--- + + +
    +
    +
    + {type === 'domains' && } + {type === 'services' && } +
    +
    +
    +
    diff --git a/eventcatalog/src/pages/architecture/[type]/index.astro b/eventcatalog/src/pages/architecture/[type]/index.astro deleted file mode 100644 index bed361c36..000000000 --- a/eventcatalog/src/pages/architecture/[type]/index.astro +++ /dev/null @@ -1,14 +0,0 @@ ---- -import Architecture from '../architecture.astro'; - -export async function getStaticPaths() { - const VALID_TYPES = ['domains', 'services', 'messages'] as const; - return VALID_TYPES.map((type) => ({ - params: { type }, - })); -} - -const { type } = Astro.params; ---- - - diff --git a/eventcatalog/src/pages/architecture/architecture.astro b/eventcatalog/src/pages/architecture/architecture.astro deleted file mode 100644 index 3eef231d3..000000000 --- a/eventcatalog/src/pages/architecture/architecture.astro +++ /dev/null @@ -1,110 +0,0 @@ ---- -import { getDomains, getMessagesForDomain } from '@utils/collections/domains'; -import { getServices } from '@utils/collections/services'; -import { getContainers } from '@utils/collections/containers'; -import { getMessages } from '@utils/messages'; -import type { ExtendedDomain } from '@components/Grids/DomainGrid'; -import VerticalSideBarLayout from '@layouts/VerticalSideBarLayout.astro'; -import DomainGrid from '@components/Grids/DomainGrid'; -import ServiceGrid from '@components/Grids/ServiceGrid'; -import MessageGrid from '@components/Grids/MessageGrid'; -import { removeContentFromCollection } from '@utils/collections/util'; - -import type { CollectionEntry } from 'astro:content'; -import type { CollectionMessageTypes } from '@types'; - -import { isVisualiserEnabled } from '@utils/feature'; - -import { ClientRouter, fade } from 'astro:transitions'; -// Define valid types and their corresponding data fetchers -const VALID_TYPES = ['domains', 'services', 'messages'] as const; -type ValidType = (typeof VALID_TYPES)[number]; - -interface Service extends CollectionEntry<'services'> { - sends: CollectionEntry<'events' | 'commands' | 'queries'>[]; - receives: CollectionEntry<'events' | 'commands' | 'queries'>[]; -} - -const { type, embeded = false } = Astro.props as { type: ValidType; embeded: boolean }; - -// Get data based on type -let items: Service[] | CollectionEntry<'commands'>[] | CollectionEntry[] = []; -let domains: ExtendedDomain[] = []; -let containers: CollectionEntry<'containers'>[] = []; - -const getDomainsForArchitecturePages = async () => { - const domains = await getDomains({ getAllVersions: false }); - - // Get messages for each domain - return Promise.all( - domains.map(async (domain) => { - const messages = await getMessagesForDomain(domain); - // @ts-ignore we have to remove markdown information, as it's all send to the astro components. This reduced the page size. - return { - ...domain, - sends: messages.sends.map((s) => ({ ...s, body: undefined, catalog: undefined })), - receives: messages.receives.map((r) => ({ ...r, body: undefined, catalog: undefined })), - catalog: undefined, - body: undefined, - } as ExtendedDomain; - }) - ); -}; - -if (type === 'domains' || type === 'services') { - domains = await getDomainsForArchitecturePages(); -} - -if (type === 'services') { - const services = await getServices({ getAllVersions: false }); - let filteredServices = services.map((s) => { - // @ts-ignore we have to remove markdown information, as it's all send to the astro components. This reduced the page size. - return { - ...s, - sends: (s.data.sends || []).map((s) => ({ ...s, body: undefined, catalog: undefined })), - receives: (s.data.receives || []).map((r) => ({ ...r, body: undefined, catalog: undefined })), - catalog: undefined, - body: undefined, - } as Service; - }) as unknown as Service[]; - items = filteredServices; -} else if (type === 'messages') { - const { events, commands, queries } = await getMessages({ getAllVersions: false, hydrateServices: false }); - const messages = [...events, ...commands, ...queries]; - items = removeContentFromCollection(messages) as unknown as CollectionEntry[]; - containers = await getContainers({ getAllVersions: false }); -} ---- - - -
    -
    -
    - {type === 'domains' && } - { - type === 'services' && ( - - ) - } - { - type === 'messages' && ( - []} - embeded={embeded} - containers={containers} - isVisualiserEnabled={isVisualiserEnabled()} - client:load - /> - ) - } -
    -
    - -
    -
    diff --git a/eventcatalog/src/pages/architecture/docs/[type]/index.astro b/eventcatalog/src/pages/architecture/docs/[type]/index.astro deleted file mode 100644 index f71565e2d..000000000 --- a/eventcatalog/src/pages/architecture/docs/[type]/index.astro +++ /dev/null @@ -1,14 +0,0 @@ ---- -import Architecture from '../../architecture.astro'; - -export async function getStaticPaths() { - const VALID_TYPES = ['domains', 'services', 'messages'] as const; - return VALID_TYPES.map((type) => ({ - params: { type }, - })); -} - -const { type } = Astro.params; ---- - - diff --git a/eventcatalog/src/pages/directory/[type]/_index.data.ts b/eventcatalog/src/pages/directory/[type]/_index.data.ts index d75e8a0c7..e8d27d8b8 100644 --- a/eventcatalog/src/pages/directory/[type]/_index.data.ts +++ b/eventcatalog/src/pages/directory/[type]/_index.data.ts @@ -8,8 +8,8 @@ export class Page extends HybridPage { } static async getStaticPaths(): Promise> { - const { getUsers } = await import('@utils/users'); - const { getTeams } = await import('@utils/teams'); + const { getUsers } = await import('@utils/collections/users'); + const { getTeams } = await import('@utils/collections/teams'); const loaders = { users: getUsers, @@ -37,8 +37,8 @@ export class Page extends HybridPage { return null; } - const { getUsers } = await import('@utils/users'); - const { getTeams } = await import('@utils/teams'); + const { getUsers } = await import('@utils/collections/users'); + const { getTeams } = await import('@utils/collections/teams'); const loaders = { users: getUsers, diff --git a/eventcatalog/src/pages/docs/[type]/[id]/[version].md.ts b/eventcatalog/src/pages/docs/[type]/[id]/[version].md.ts index 43bb00f1c..4455a301e 100644 --- a/eventcatalog/src/pages/docs/[type]/[id]/[version].md.ts +++ b/eventcatalog/src/pages/docs/[type]/[id]/[version].md.ts @@ -4,7 +4,7 @@ import type { APIRoute } from 'astro'; import { getCollection } from 'astro:content'; -import { getEntities } from '@utils/entities'; +import { getEntities } from '@utils/collections/entities'; import config from '@config'; import fs from 'fs'; diff --git a/eventcatalog/src/pages/docs/[type]/[id]/[version]/_index.data.ts b/eventcatalog/src/pages/docs/[type]/[id]/[version]/_index.data.ts index 9f2eecdd7..9926d59d7 100644 --- a/eventcatalog/src/pages/docs/[type]/[id]/[version]/_index.data.ts +++ b/eventcatalog/src/pages/docs/[type]/[id]/[version]/_index.data.ts @@ -32,10 +32,7 @@ export class Page extends HybridPage { id: item.data.id, version: item.data.version, }, - props: { - type: itemTypes[index], - ...item, - }, + props: {}, })) ); } diff --git a/eventcatalog/src/pages/docs/[type]/[id]/[version]/changelog/_index.data.ts b/eventcatalog/src/pages/docs/[type]/[id]/[version]/changelog/_index.data.ts index 6acd48456..49a02c04d 100644 --- a/eventcatalog/src/pages/docs/[type]/[id]/[version]/changelog/_index.data.ts +++ b/eventcatalog/src/pages/docs/[type]/[id]/[version]/changelog/_index.data.ts @@ -1,4 +1,4 @@ -import { isSSR } from '@utils/feature'; +import { isSSR, isChangelogEnabled } from '@utils/feature'; import { HybridPage } from '@utils/page-loaders/hybrid-page'; import type { PageTypes } from '@types'; @@ -8,7 +8,7 @@ export class Page extends HybridPage { } static async getStaticPaths(): Promise> { - if (isSSR()) { + if (isSSR() || !isChangelogEnabled()) { return []; } @@ -36,7 +36,7 @@ export class Page extends HybridPage { protected static async fetchData(params: any) { const { type, id, version } = params; - if (!type || !id || !version) { + if (!type || !id || !version || !isChangelogEnabled()) { return null; } diff --git a/eventcatalog/src/pages/docs/[type]/[id]/[version]/changelog/index.astro b/eventcatalog/src/pages/docs/[type]/[id]/[version]/changelog/index.astro index 1f9af4f40..cc74d6c93 100644 --- a/eventcatalog/src/pages/docs/[type]/[id]/[version]/changelog/index.astro +++ b/eventcatalog/src/pages/docs/[type]/[id]/[version]/changelog/index.astro @@ -16,7 +16,7 @@ import 'diff2html/bundles/css/diff2html.min.css'; import { buildUrl } from '@utils/url-builder'; import { getPreviousVersion } from '@utils/collections/util'; -import { getDiffsForCurrentAndPreviousVersion } from '@utils/collections/file-diffs'; +import { getDiffsForCurrentAndPreviousVersion } from '@utils/file-diffs'; import VerticalSideBarLayout from '@layouts/VerticalSideBarLayout.astro'; import { ClientRouter } from 'astro:transitions'; import { isChangelogEnabled } from '@utils/feature'; @@ -29,10 +29,6 @@ export const getStaticPaths = Page.getStaticPaths; // Get data const props = await Page.getData(Astro); -if (!isChangelogEnabled()) { - return Astro.redirect('/docs'); -} - let collectionItem = props; const logs = await getChangeLogs(props); diff --git a/eventcatalog/src/pages/docs/[type]/[id]/[version]/index.astro b/eventcatalog/src/pages/docs/[type]/[id]/[version]/index.astro index 59cf81312..775049a1d 100644 --- a/eventcatalog/src/pages/docs/[type]/[id]/[version]/index.astro +++ b/eventcatalog/src/pages/docs/[type]/[id]/[version]/index.astro @@ -1,26 +1,21 @@ --- +// External dependencies +import { marked } from 'marked'; + +import { render } from 'astro:content'; +import type { CollectionEntry } from 'astro:content'; + import VerticalSideBarLayout from '@layouts/VerticalSideBarLayout.astro'; import Footer from '@layouts/Footer.astro'; -import { marked } from 'marked'; import components from '@components/MDX/components'; import NodeGraph from '@components/MDX/NodeGraph/NodeGraph.astro'; import SchemaViewer from '@components/MDX/SchemaViewer/SchemaViewerRoot.astro'; import Admonition from '@components/MDX/Admonition'; - -import { getSpecificationsForService } from '@utils/collections/services'; - -import { resourceToCollectionMap, collectionToResourceMap } from '@utils/collections/util'; - -// SideBars -import ServiceSideBar from '@components/SideBars/ServiceSideBar.astro'; -import MessageSideBar from '@components/SideBars/MessageSideBar.astro'; -import DomainSideBar from '@components/SideBars/DomainSideBar.astro'; -import ChannelSideBar from '@components/SideBars/ChannelSideBar.astro'; -import FlowSideBar from '@components/SideBars/FlowSideBar.astro'; -import EntitySideBar from '@components/SideBars/EntitySideBar.astro'; -import ContainerSideBar from '@components/SideBars/ContainerSideBar.astro'; +import VersionList from '@components/Lists/VersionList.astro'; import CopyAsMarkdown from '@components/CopyAsMarkdown'; +import FavoriteButton from '@components/FavoriteButton'; +import { shouldRenderSideBarSection } from '@components/SideNav/NestedSideBar/builders/shared'; import { QueueListIcon, @@ -33,21 +28,26 @@ import { ClockIcon, } from '@heroicons/react/24/outline'; import { ArrowsRightLeftIcon } from '@heroicons/react/20/solid'; -import { Box, Boxes, SquarePenIcon, DatabaseIcon, DatabaseZapIcon, ShieldCheckIcon } from 'lucide-react'; -import type { CollectionTypes } from '@types'; - -import { render } from 'astro:content'; -import type { CollectionEntry } from 'astro:content'; +import { Box, Boxes, SquarePenIcon, DatabaseIcon, DatabaseZapIcon, ShieldCheckIcon, AlignLeft } from 'lucide-react'; +import { getSpecificationsForService } from '@utils/collections/services'; +import { resourceToCollectionMap, collectionToResourceMap, getDeprecatedDetails } from '@utils/collections/util'; +import { getSchemasFromResource } from '@utils/collections/schemas'; import { getIcon } from '@utils/badges'; -import { getDeprecatedDetails } from '@utils/collections/util'; import { buildUrl, buildEditUrlForResource } from '@utils/url-builder'; -import { getSchemasFromResource } from '@utils/collections/schemas'; -import { isEventCatalogChatEnabled, isMarkdownDownloadEnabled, isVisualiserEnabled } from '@utils/feature'; - +import { + isEventCatalogChatEnabled, + isMarkdownDownloadEnabled, + isVisualiserEnabled, + isChangelogEnabled, + isRSSEnabled, +} from '@utils/feature'; import { getMDXComponentsByName } from '@utils/markdown'; +import type { CollectionTypes } from '@types'; + import config from '@config'; + import { Page } from './_index.data'; export const prerender = Page.prerender; @@ -56,7 +56,7 @@ export const getStaticPaths = Page.getStaticPaths; // Get data const props = await Page.getData(Astro); -const { Content } = await render(props); +const { Content, headings } = await render(props); const pageTitle = `${props.collection} | ${props.data.name}`.replace(/^\w/, (c) => c.toUpperCase()); const contentBadges = props.data.badges || []; @@ -259,6 +259,9 @@ let nodeGraphs = getMDXComponentsByName(props.body || '', 'NodeGraph') || []; // Get props for the node graph (when no id is passed, we assume its the current page) const nodeGraphPropsForPage = nodeGraphs.find((nodeGraph: any) => nodeGraph.id === undefined) || ({} as any); +const shouldRenderVersionList = + shouldRenderSideBarSection(props, 'versions') && props.data.versions && props.data.versions.length > 1; + // This will render the graph for this page nodeGraphs.push({ id: props.data.id, @@ -271,19 +274,31 @@ nodeGraphs.push({ --- -
    -
    +
    +
    -

    - {props.data.name} - (v{props.data.version}) -

    +
    +

    + {props.data.name} + (v{props.data.version}) +

    + +
    @@ -475,185 +491,337 @@ nodeGraphs.push({ }
    -
    -
    - + /* Fix for architecture diagrams */ + .mermaid[data-content*='architecture'] svg { + max-width: 350px !important; + margin: 0; + /* width: 100px !important; */ + } + - + window.eventcatalog.mermaid = config.mermaid; + - + + + function encodePlantUML(text: any, deflate: any) { + const utf8encoded = new TextEncoder().encode(text); + const compressed = deflate(utf8encoded, { level: 9 }); + return encode64(compressed); + } - + + setupObserver(); + + document.addEventListener('astro:page-load', setupObserver); + +
    diff --git a/eventcatalog/src/pages/docs/[type]/[id]/index.astro b/eventcatalog/src/pages/docs/[type]/[id]/index.astro index 251fbe6bd..708647eed 100644 --- a/eventcatalog/src/pages/docs/[type]/[id]/index.astro +++ b/eventcatalog/src/pages/docs/[type]/[id]/index.astro @@ -1,14 +1,14 @@ --- import Seo from '@components/Seo.astro'; import { buildUrl } from '@utils/url-builder'; -import { getEvents } from '@utils/events'; -import { getEntities } from '@utils/entities'; -import { getCommands } from '@utils/commands'; +import { getEvents } from '@utils/collections/events'; +import { getEntities } from '@utils/collections/entities'; +import { getCommands } from '@utils/collections/commands'; import { getServices } from '@utils/collections/services'; import { getDomains } from '@utils/collections/domains'; import type { CollectionEntry } from 'astro:content'; import type { CollectionTypes } from '@types'; -import { getChannels } from '@utils/channels'; +import { getChannels } from '@utils/collections/channels'; export async function getStaticPaths() { const [events, commands, services, domains, channels, entities] = await Promise.all([ diff --git a/eventcatalog/src/pages/docs/[type]/[id]/language/_index.data.ts b/eventcatalog/src/pages/docs/[type]/[id]/language/_index.data.ts index 4b26f4ec2..df6b3a945 100644 --- a/eventcatalog/src/pages/docs/[type]/[id]/language/_index.data.ts +++ b/eventcatalog/src/pages/docs/[type]/[id]/language/_index.data.ts @@ -18,10 +18,7 @@ export class Page extends HybridPage { type: item.collection, id: item.data.id, }, - props: { - type: item.collection, - ...item, - }, + props: {}, })); } diff --git a/eventcatalog/src/pages/docs/[type]/[id]/language/index.astro b/eventcatalog/src/pages/docs/[type]/[id]/language/index.astro index 75b9ab99e..3b1fa8c17 100644 --- a/eventcatalog/src/pages/docs/[type]/[id]/language/index.astro +++ b/eventcatalog/src/pages/docs/[type]/[id]/language/index.astro @@ -25,36 +25,12 @@ const { subdomains, duplicateTerms } = ubiquitousLanguageData;
    - {/* Breadcrumb */} - - {/* Title Section */}
    -

    Ubiquitous Language Explorer

    +

    Ubiquitous Language

    Browse and discover ubiquitous language terms in the {props.data.name} domain{ @@ -145,7 +121,7 @@ const { subdomains, duplicateTerms } = ubiquitousLanguageData; {term.description && (

    Read more @@ -213,7 +189,7 @@ const { subdomains, duplicateTerms } = ubiquitousLanguageData; {term.description && (
    Read more diff --git a/eventcatalog/src/pages/docs/teams/[id]/_index.data.ts b/eventcatalog/src/pages/docs/teams/[id]/_index.data.ts index 9246a5168..c460f3bc0 100644 --- a/eventcatalog/src/pages/docs/teams/[id]/_index.data.ts +++ b/eventcatalog/src/pages/docs/teams/[id]/_index.data.ts @@ -12,7 +12,7 @@ export class Page extends HybridPage { return []; } - const { getTeams } = await import('@utils/teams'); + const { getTeams } = await import('@utils/collections/teams'); const teams = await getTeams(); return teams.map((team) => ({ @@ -28,7 +28,7 @@ export class Page extends HybridPage { return null; } - const { getTeams } = await import('@utils/teams'); + const { getTeams } = await import('@utils/collections/teams'); const teams = await getTeams(); // Find the specific team by id diff --git a/eventcatalog/src/pages/docs/users/[id]/_index.data.ts b/eventcatalog/src/pages/docs/users/[id]/_index.data.ts index 14dee2b2d..719033e43 100644 --- a/eventcatalog/src/pages/docs/users/[id]/_index.data.ts +++ b/eventcatalog/src/pages/docs/users/[id]/_index.data.ts @@ -12,7 +12,7 @@ export class Page extends HybridPage { return []; } - const { getUsers } = await import('@utils/users'); + const { getUsers } = await import('@utils/collections/users'); const users = await getUsers(); return users.map((user) => ({ @@ -28,7 +28,7 @@ export class Page extends HybridPage { return null; } - const { getUsers } = await import('@utils/users'); + const { getUsers } = await import('@utils/collections/users'); const users = await getUsers(); // Find the specific team by id diff --git a/eventcatalog/src/pages/nav-index.json.ts b/eventcatalog/src/pages/nav-index.json.ts new file mode 100644 index 000000000..b41ff67d8 --- /dev/null +++ b/eventcatalog/src/pages/nav-index.json.ts @@ -0,0 +1,30 @@ +// src/pages/nav-index.json.ts +import { getCollection } from 'astro:content'; + +export const prerender = true; + +export async function GET() { + // const services = await getCollection('services'); + // const domains = await getCollection('domains'); + // // ...other collections + + // const index = buildNavIndex({ services, domains }); // your map logic + + const index = [ + { + type: 'group', + title: 'Domains', + pages: [ + { + type: 'item', + title: 'Inventory Domain', + icon: 'ServerIcon', + }, + ], + }, + ]; + + return new Response(JSON.stringify(index), { + headers: { 'Content-Type': 'application/json' }, + }); +} diff --git a/eventcatalog/src/pages/schemas/[type]/[id]/[version]/_index.data.ts b/eventcatalog/src/pages/schemas/[type]/[id]/[version]/_index.data.ts new file mode 100644 index 000000000..5b066d4d9 --- /dev/null +++ b/eventcatalog/src/pages/schemas/[type]/[id]/[version]/_index.data.ts @@ -0,0 +1,77 @@ +import { isSSR } from '@utils/feature'; +import { HybridPage } from '@utils/page-loaders/hybrid-page'; +import type { PageTypes } from '@types'; +import { pageDataLoader } from '@utils/page-loaders/page-data-loader'; + +/** + * Documentation page class for all collection types with versioning + */ +export class Page extends HybridPage { + static async getStaticPaths() { + if (isSSR()) { + return []; + } + + const itemTypes: PageTypes[] = [ + 'events', + 'commands', + 'queries', + // 'services', + // 'domains', + // 'flows', + // 'channels', + // 'entities', + // 'containers', + ]; + const allItems = await Promise.all(itemTypes.map((type) => pageDataLoader[type]())); + + // We only care about any item that has data.schemaPath + const itemsWithSchema = allItems.flatMap((items) => items.filter((item) => item.data.schemaPath)); + + // return allItems.flatMap((items, index) => + return itemsWithSchema.map((item, index) => ({ + params: { + type: item.collection, + id: item.data.id, + version: item.data.version, + }, + props: { + type: item.collection, + ...item, + // Not everything needs the body of the page itself. + body: undefined, + }, + })); + // ); + } + + protected static async fetchData(params: any) { + const { type, id, version } = params; + + if (!type || !id || !version) { + return null; + } + + // Get all items of the specified type + const items = await pageDataLoader[type as PageTypes](); + + // Find the specific item by id and version + const item = items.find((i) => i.data.id === id && i.data.version === version); + + if (!item) { + return null; + } + + return { + type, + ...item, + }; + } + + protected static createNotFoundResponse(): Response { + return new Response(null, { + status: 404, + statusText: 'Documentation not found', + }); + } +} diff --git a/eventcatalog/src/pages/schemas/[type]/[id]/[version]/index.astro b/eventcatalog/src/pages/schemas/[type]/[id]/[version]/index.astro new file mode 100644 index 000000000..57d4bcd07 --- /dev/null +++ b/eventcatalog/src/pages/schemas/[type]/[id]/[version]/index.astro @@ -0,0 +1,90 @@ +--- +import type { PageTypes } from '@types'; +import VerticalSideBarLayout from '@layouts/VerticalSideBarLayout.astro'; +import { Page } from './_index.data'; +import SchemaPageViewer from '@components/SchemaExplorer/SchemaPageViewer'; +import { pageDataLoader } from '@utils/page-loaders/page-data-loader'; +import { sortVersioned } from '@utils/collections/util'; +import { isEventCatalogScaleEnabled } from '@utils/feature'; +import fs from 'fs'; +import path from 'path'; +import type { SchemaItem } from '@components/SchemaExplorer/types'; + +export const prerender = Page.prerender; +export const getStaticPaths = Page.getStaticPaths; + +// Get data +const props = await Page.getData(Astro); +const { type, data } = props as { type: PageTypes; data: any }; +const pageTitle = `${type} | ${data.name}`.replace(/^\w/, (c) => c.toUpperCase()); + +const allItems = await pageDataLoader[type](); +const versions = allItems.filter((item) => item.data.id === data.id); + +// Transform to SchemaItems +const availableVersions = await Promise.all( + versions + .filter((message) => message.data.schemaPath) + .filter((message) => fs.existsSync(path.join(path.dirname(message.filePath ?? ''), message.data.schemaPath ?? ''))) + .map(async (message) => { + try { + const schemaPath = message.data.schemaPath; + const fullSchemaPath = path.join(path.dirname(message.filePath ?? ''), schemaPath ?? ''); + + let schemaContent = ''; + if (fs.existsSync(fullSchemaPath)) { + schemaContent = fs.readFileSync(fullSchemaPath, 'utf-8'); + } + + const schemaExtension = path.extname(schemaPath ?? '').slice(1); + + return { + collection: message.collection, + data: { + id: message.data.id, + name: message.data.name, + version: message.data.version, + summary: message.data.summary, + schemaPath: message.data.schemaPath, + // @ts-ignore + producers: message.data.producers || [], + // @ts-ignore + consumers: message.data.consumers || [], + }, + schemaContent, + schemaExtension, + } as SchemaItem; + } catch (error) { + console.error(`Error reading schema for ${message.data.id}:`, error); + return null; + } + }) +); + +const validVersions = availableVersions.filter((v): v is SchemaItem => v !== null); + +// Sort versions descending (newest first) +const sortedVersions = sortVersioned(validVersions, (item) => item.data.version); + +const currentMessage = sortedVersions.find((v) => v.data.version === data.version); +const apiAccessEnabled = isEventCatalogScaleEnabled(); +--- + + +
    + { + currentMessage ? ( + + ) : ( +
    Schema not found or could not be loaded.
    + ) + } +
    +
    diff --git a/eventcatalog/src/pages/schemas/index.astro b/eventcatalog/src/pages/schemas/explorer/index.astro similarity index 97% rename from eventcatalog/src/pages/schemas/index.astro rename to eventcatalog/src/pages/schemas/explorer/index.astro index c0e761b40..492db7cd8 100644 --- a/eventcatalog/src/pages/schemas/index.astro +++ b/eventcatalog/src/pages/schemas/explorer/index.astro @@ -1,8 +1,8 @@ --- import VerticalSideBarLayout from '@layouts/VerticalSideBarLayout.astro'; -import { getEvents } from '@utils/events'; -import { getCommands } from '@utils/commands'; -import { getQueries } from '@utils/queries'; +import { getEvents } from '@utils/collections/events'; +import { getCommands } from '@utils/collections/commands'; +import { getQueries } from '@utils/collections/queries'; import { getServices, getSpecificationsForService } from '@utils/collections/services'; import SchemaExplorer from '@components/SchemaExplorer/SchemaExplorer'; import { isEventCatalogScaleEnabled } from '@utils/feature'; diff --git a/eventcatalog/src/pages/studio.astro b/eventcatalog/src/pages/studio.astro index 9623e8b1f..410c2c295 100644 --- a/eventcatalog/src/pages/studio.astro +++ b/eventcatalog/src/pages/studio.astro @@ -1,12 +1,12 @@ --- import VerticalSideBarLayout from '@layouts/VerticalSideBarLayout.astro'; -import { getEvents } from '@utils/events'; -import { getCommands } from '@utils/commands'; +import { getEvents } from '@utils/collections/events'; +import { getCommands } from '@utils/collections/commands'; import { getServices } from '@utils/collections/services'; import { BoltIcon, ServerIcon, RectangleGroupIcon } from '@heroicons/react/24/outline'; import { SquareDashedMousePointerIcon, ArrowLeftRightIcon } from 'lucide-react'; import StudioPageModal from '@components/Studio/StudioPageModal'; -import { getChannels } from '@utils/channels'; +import { getChannels } from '@utils/collections/channels'; const [events, commands, services, channels] = await Promise.all([ getEvents().catch(() => []), diff --git a/eventcatalog/src/pages/visualiser/[type]/[id]/index.astro b/eventcatalog/src/pages/visualiser/[type]/[id]/index.astro index b0c1156a7..a5683c017 100644 --- a/eventcatalog/src/pages/visualiser/[type]/[id]/index.astro +++ b/eventcatalog/src/pages/visualiser/[type]/[id]/index.astro @@ -1,8 +1,8 @@ --- import Seo from '@components/Seo.astro'; import { buildUrl } from '@utils/url-builder'; -import { getEvents } from '@utils/events'; -import { getCommands } from '@utils/commands'; +import { getEvents } from '@utils/collections/events'; +import { getCommands } from '@utils/collections/commands'; import { getServices } from '@utils/collections/services'; import { getDomains } from '@utils/collections/domains'; import { getContainers } from '@utils/collections/containers'; diff --git a/eventcatalog/src/stores/favorites-store.ts b/eventcatalog/src/stores/favorites-store.ts new file mode 100644 index 000000000..a79211518 --- /dev/null +++ b/eventcatalog/src/stores/favorites-store.ts @@ -0,0 +1,83 @@ +import { atom } from 'nanostores'; + +const FAVORITES_KEY = 'eventcatalog-sidebar-favorites'; + +export type FavoriteItem = { + nodeKey: string; // The key of the favorited node + path: string[]; // Path of keys to reach this node + title: string; // Display title + badge?: string; // Type badge (Domain, Service, etc.) + href?: string; // Direct link if it's a leaf item +}; + +export const favoritesStore = atom([]); + +// Initialize store from localStorage on client side +const initStore = () => { + if (typeof window !== 'undefined') { + try { + const stored = localStorage.getItem(FAVORITES_KEY); + if (stored) { + const parsed = JSON.parse(stored); + if (JSON.stringify(favoritesStore.get()) !== stored) { + favoritesStore.set(parsed); + } + } + } catch (e) { + console.warn('Failed to load favorites:', e); + } + } +}; + +if (typeof window !== 'undefined') { + initStore(); + + // Listen for storage events (cross-tab sync) + window.addEventListener('storage', (e) => { + if (e.key === FAVORITES_KEY) { + initStore(); + } + }); + + // Listen for custom events (same-tab sync between instances) + window.addEventListener('favorites-updated', () => { + initStore(); + }); +} + +export const saveFavorites = (favorites: FavoriteItem[]) => { + favoritesStore.set(favorites); + if (typeof window !== 'undefined') { + try { + localStorage.setItem(FAVORITES_KEY, JSON.stringify(favorites)); + // Dispatch event to notify other store instances/components + window.dispatchEvent(new CustomEvent('favorites-updated')); + } catch (e) { + console.warn('Failed to save favorites:', e); + } + } +}; + +export const addFavorite = (item: FavoriteItem) => { + const current = favoritesStore.get(); + const exists = current.some((fav) => fav.nodeKey === item.nodeKey); + if (!exists) { + saveFavorites([...current, item]); + } +}; + +export const removeFavorite = (nodeKey: string) => { + const current = favoritesStore.get(); + saveFavorites(current.filter((fav) => fav.nodeKey !== nodeKey)); +}; + +export const toggleFavorite = (item: FavoriteItem) => { + const current = favoritesStore.get(); + const exists = current.some((fav) => fav.nodeKey === item.nodeKey); + + if (exists) { + removeFavorite(item.nodeKey); + } else { + addFavorite(item); + } +}; diff --git a/eventcatalog/src/stores/sidebar-store.ts b/eventcatalog/src/stores/sidebar-store.ts new file mode 100644 index 000000000..0aa287e13 --- /dev/null +++ b/eventcatalog/src/stores/sidebar-store.ts @@ -0,0 +1,8 @@ +import { atom } from 'nanostores'; +import type { NavigationData } from '../components/SideNav/NestedSideBar/sidebar-builder'; + +export const sidebarStore = atom(null); + +export const setSidebarData = (data: NavigationData) => { + sidebarStore.set(data); +}; diff --git a/eventcatalog/src/utils/__tests__/channels/channels.spec.ts b/eventcatalog/src/utils/__tests__/channels/channels.spec.ts index 9871e319a..fdecab47c 100644 --- a/eventcatalog/src/utils/__tests__/channels/channels.spec.ts +++ b/eventcatalog/src/utils/__tests__/channels/channels.spec.ts @@ -1,7 +1,7 @@ import type { CollectionEntry, ContentCollectionKey } from 'astro:content'; import { expect, describe, it, vi } from 'vitest'; import { mockCommands, mockEvents, mockQueries, mockServices, mockChannels } from './mocks'; -import { getChannels, getChannelChain, isChannelsConnected } from '@utils/channels'; +import { getChannels, getChannelChain, isChannelsConnected } from '@utils/collections/channels'; vi.mock('astro:content', async (importOriginal) => { return { diff --git a/eventcatalog/src/utils/__tests__/collections/file-diffs.spec.ts b/eventcatalog/src/utils/__tests__/collections/file-diffs.spec.ts index e4768b819..e585713ac 100644 --- a/eventcatalog/src/utils/__tests__/collections/file-diffs.spec.ts +++ b/eventcatalog/src/utils/__tests__/collections/file-diffs.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { getDiffsForCurrentAndPreviousVersion } from '@utils/collections/file-diffs'; +import { getDiffsForCurrentAndPreviousVersion } from '@utils/file-diffs'; import { join } from 'node:path'; const pathToTestCatalog = join(__dirname, 'fake-catalog'); diff --git a/eventcatalog/src/utils/__tests__/commands/commands.spec.ts b/eventcatalog/src/utils/__tests__/commands/commands.spec.ts index 3df5273e5..4f39c98f4 100644 --- a/eventcatalog/src/utils/__tests__/commands/commands.spec.ts +++ b/eventcatalog/src/utils/__tests__/commands/commands.spec.ts @@ -1,7 +1,7 @@ import type { ContentCollectionKey } from 'astro:content'; import { expect, describe, it, vi } from 'vitest'; import { mockServices, mockCommands } from './mocks'; -import { getCommands } from '@utils/commands'; +import { getCommands } from '@utils/collections/commands'; vi.mock('astro:content', async (importOriginal) => { return { diff --git a/eventcatalog/src/utils/__tests__/domains/node-graph.spec.ts b/eventcatalog/src/utils/__tests__/domains/node-graph.spec.ts index 60729bbaa..c98719184 100644 --- a/eventcatalog/src/utils/__tests__/domains/node-graph.spec.ts +++ b/eventcatalog/src/utils/__tests__/domains/node-graph.spec.ts @@ -114,89 +114,89 @@ describe('Domains NodeGraph', () => { expect(nodes).toEqual(expect.arrayContaining([expect.objectContaining(expectedEventNode)])); - expect(nodes.length).toEqual(10); - expect(edges.length).toEqual(9); + expect(nodes.length).toEqual(9); + expect(edges.length).toEqual(8); }); - it('should return nodes and edges for a given domain with services using semver range or latest version (version undefind)', async () => { - // @ts-ignore - const { nodes, edges } = await getNodesAndEdges({ id: 'Checkout', version: '0.0.1' }); - - const expectedNodes = [ - { - id: 'PlaceOrder-1.7.7', - sourcePosition: 'right', - targetPosition: 'left', - data: { mode: 'simple', message: { ...mockCommands[2].data } }, - position: { x: expect.any(Number), y: expect.any(Number) }, - type: 'commands', - }, - { - id: 'OrderService-1.0.0', - sourcePosition: 'right', - targetPosition: 'left', - data: { - mode: 'simple', - service: { ...mockServices[2].data }, - }, - position: { x: expect.any(Number), y: expect.any(Number) }, - type: 'services', - }, - { - id: 'OrderPlaced-0.0.1', - sourcePosition: 'right', - targetPosition: 'left', - data: { mode: 'simple', message: { ...mockEvents[0].data } }, - position: { x: expect.any(Number), y: expect.any(Number) }, - type: 'events', - }, - /** PAYMENT SERVICE */ - { - id: 'PaymentService-0.0.1', - sourcePosition: 'right', - targetPosition: 'left', - data: { - mode: 'simple', - service: { ...mockServices[3].data }, - }, - position: { x: expect.any(Number), y: expect.any(Number) }, - type: 'services', - }, - { - id: 'PaymentPaid-0.0.1', - sourcePosition: 'right', - targetPosition: 'left', - data: { mode: 'simple', message: { ...mockEvents[1].data } }, - position: { x: expect.any(Number), y: expect.any(Number) }, - type: 'events', - }, - { - id: 'PaymentPaid-0.0.2', - sourcePosition: 'right', - targetPosition: 'left', - data: { mode: 'simple', message: { ...mockEvents[2].data } }, - position: { x: expect.any(Number), y: expect.any(Number) }, - type: 'events', - }, - { - id: 'PaymentRefunded-1.0.0', - sourcePosition: 'right', - targetPosition: 'left', - data: { mode: 'simple', message: { ...mockEvents[4].data } }, - position: { x: expect.any(Number), y: expect.any(Number) }, - type: 'events', - }, - { - id: 'PaymentFailed-1.0.0', - sourcePosition: 'right', - targetPosition: 'left', - data: { mode: 'simple', message: { ...mockEvents[6].data } }, - position: { x: expect.any(Number), y: expect.any(Number) }, - type: 'events', - }, - ]; - - expect(nodes).toStrictEqual(expect.arrayContaining(expectedNodes.map((n) => expect.objectContaining(n)))); - }); + // it.only('should return nodes and edges for a given domain with services using semver range or latest version (version undefind)', async () => { + // // @ts-ignore + // const { nodes, edges } = await getNodesAndEdges({ id: 'Checkout', version: '0.0.1' }); + + // const expectedNodes = [ + // { + // id: 'PlaceOrder-1.7.7', + // sourcePosition: 'right', + // targetPosition: 'left', + // data: { mode: 'simple', message: { ...mockCommands[2].data } }, + // position: { x: expect.any(Number), y: expect.any(Number) }, + // type: 'commands', + // }, + // { + // id: 'OrderService-1.0.0', + // sourcePosition: 'right', + // targetPosition: 'left', + // data: { + // mode: 'simple', + // service: { ...mockServices[2].data }, + // }, + // position: expect.anything(), + // type: 'services', + // }, + // { + // id: 'OrderPlaced-0.0.1', + // sourcePosition: 'right', + // targetPosition: 'left', + // data: { mode: 'simple', message: { ...mockEvents[0].data } }, + // position: expect.anything(), + // type: 'events', + // }, + // /** PAYMENT SERVICE */ + // { + // id: 'PaymentService-0.0.1', + // sourcePosition: 'right', + // targetPosition: 'left', + // data: { + // mode: 'simple', + // service: { ...mockServices[3].data }, + // }, + // position: expect.anything(), + // type: 'services', + // }, + // { + // id: 'PaymentPaid-0.0.1', + // sourcePosition: 'right', + // targetPosition: 'left', + // data: { mode: 'simple', message: { ...mockEvents[1].data } }, + // position: expect.anything(), + // type: 'events', + // }, + // { + // id: 'PaymentPaid-0.0.2', + // sourcePosition: 'right', + // targetPosition: 'left', + // data: { mode: 'simple', message: { ...mockEvents[2].data } }, + // position: expect.anything(), + // type: 'events', + // }, + // { + // id: 'PaymentRefunded-1.0.0', + // sourcePosition: 'right', + // targetPosition: 'left', + // data: { mode: 'simple', message: { ...mockEvents[4].data } }, + // position: expect.anything(), + // type: 'events', + // }, + // { + // id: 'PaymentFailed-1.0.0', + // sourcePosition: 'right', + // targetPosition: 'left', + // data: { mode: 'simple', message: { ...mockEvents[6].data } }, + // position: expect.anything(), + // type: 'events', + // }, + // ]; + + // expect(nodes).toStrictEqual(expect.arrayContaining(expectedNodes.map((n) => expect.objectContaining(n)))); + // }); }); }); diff --git a/eventcatalog/src/utils/__tests__/entities/entities.spec.ts b/eventcatalog/src/utils/__tests__/entities/entities.spec.ts index 6ad8eaf05..261cbbe15 100644 --- a/eventcatalog/src/utils/__tests__/entities/entities.spec.ts +++ b/eventcatalog/src/utils/__tests__/entities/entities.spec.ts @@ -1,7 +1,7 @@ import type { ContentCollectionKey } from 'astro:content'; import { expect, describe, it, vi } from 'vitest'; import { mockServices, mockEntities, mockDomains } from './mocks'; -import { getEntities } from '@utils/entities'; +import { getEntities } from '@utils/collections/entities'; vi.mock('astro:content', async (importOriginal) => { return { diff --git a/eventcatalog/src/utils/__tests__/events/events.spec.ts b/eventcatalog/src/utils/__tests__/events/events.spec.ts index e778380d7..f7cf0cf5a 100644 --- a/eventcatalog/src/utils/__tests__/events/events.spec.ts +++ b/eventcatalog/src/utils/__tests__/events/events.spec.ts @@ -1,7 +1,7 @@ import type { ContentCollectionKey } from 'astro:content'; import { expect, describe, it, vi } from 'vitest'; import { mockServices, mockEvents } from './mocks'; -import { getEvents } from '@utils/events'; +import { getEvents } from '@utils/collections/events'; vi.mock('astro:content', async (importOriginal) => { return { diff --git a/eventcatalog/src/utils/__tests__/messages/messages.spec.ts b/eventcatalog/src/utils/__tests__/messages/messages.spec.ts index d26084d47..0546bd155 100644 --- a/eventcatalog/src/utils/__tests__/messages/messages.spec.ts +++ b/eventcatalog/src/utils/__tests__/messages/messages.spec.ts @@ -1,7 +1,7 @@ import type { CollectionEntry, ContentCollectionKey } from 'astro:content'; import { expect, describe, it, vi } from 'vitest'; import { mockCommands, mockEvents, mockQueries, mockServices, mockChannels } from './mocks'; -import { getMessages } from '@utils/messages'; +import { getMessages } from '@utils/collections/messages'; vi.mock('astro:content', async (importOriginal) => { return { diff --git a/eventcatalog/src/utils/__tests__/queries/queries.spec.ts b/eventcatalog/src/utils/__tests__/queries/queries.spec.ts index 6e4907c91..a508d8086 100644 --- a/eventcatalog/src/utils/__tests__/queries/queries.spec.ts +++ b/eventcatalog/src/utils/__tests__/queries/queries.spec.ts @@ -1,7 +1,7 @@ import type { ContentCollectionKey } from 'astro:content'; import { expect, describe, it, vi } from 'vitest'; import { mockServices, mockQueries } from './mocks'; -import { getQueries } from '@utils/queries'; +import { getQueries } from '@utils/collections/queries'; vi.mock('astro:content', async (importOriginal) => { return { diff --git a/eventcatalog/src/utils/__tests__/services/services.spec.ts b/eventcatalog/src/utils/__tests__/services/services.spec.ts index 121bc09b4..9732b054b 100644 --- a/eventcatalog/src/utils/__tests__/services/services.spec.ts +++ b/eventcatalog/src/utils/__tests__/services/services.spec.ts @@ -25,6 +25,8 @@ vi.mock('astro:content', async (importOriginal) => { return Promise.resolve(mockQueries); case 'containers': return Promise.resolve(mockContainers); + default: + return Promise.resolve([]); } }, }; diff --git a/eventcatalog/src/utils/collections/changelogs.ts b/eventcatalog/src/utils/collections/changelogs.ts index 84b3a9570..c9f403354 100644 --- a/eventcatalog/src/utils/collections/changelogs.ts +++ b/eventcatalog/src/utils/collections/changelogs.ts @@ -7,12 +7,15 @@ export type ChangeLog = CollectionEntry<'changelogs'>; export const getChangeLogs = async (item: CollectionEntry): Promise => { const { collection, data, filePath } = item; + const collectionDirectory = path.dirname(item?.filePath || ''); + // Ensure the path follows /versioned//changelog.mdx + // Pre-compile regex + const versionedPathPattern = new RegExp(`${collectionDirectory}/versioned/[^/]+/changelog\\.mdx$`); + const rootChangeLogPath = path.join(collectionDirectory, 'changelog.mdx'); + // Get all logs for collection type and filter by given collection const logs = await getCollection('changelogs', (log) => { - const collectionDirectory = path.dirname(item?.filePath || ''); - const isRootChangeLog = path.join(collectionDirectory, 'changelog.mdx') === log.filePath; - // Ensure the path follows /versioned//changelog.mdx - const versionedPathPattern = new RegExp(`${collectionDirectory}/versioned/[^/]+/changelog\\.mdx$`); + const isRootChangeLog = rootChangeLogPath === log.filePath; const isVersionedChangeLog = versionedPathPattern.test(log.filePath!); return log.id.includes(`${collection}/`) && (isRootChangeLog || isVersionedChangeLog); }); diff --git a/eventcatalog/src/utils/channels.ts b/eventcatalog/src/utils/collections/channels.ts similarity index 57% rename from eventcatalog/src/utils/channels.ts rename to eventcatalog/src/utils/collections/channels.ts index 04e9de0d2..d76d206a5 100644 --- a/eventcatalog/src/utils/channels.ts +++ b/eventcatalog/src/utils/collections/channels.ts @@ -1,18 +1,19 @@ import { getCollection } from 'astro:content'; import type { CollectionEntry } from 'astro:content'; import path from 'path'; -import { getItemsFromCollectionByIdAndSemverOrLatest, getVersionForCollectionItem, satisfies } from './collections/util'; -import { getMessages } from './messages'; +import { getItemsFromCollectionByIdAndSemverOrLatest, createVersionedMap, satisfies } from './util'; import type { CollectionMessageTypes } from '@types'; import utils from '@eventcatalog/sdk'; const PROJECT_DIR = process.env.PROJECT_DIR || process.cwd(); +const CACHE_ENABLED = process.env.DISABLE_EVENTCATALOG_CACHE !== 'true'; type Channel = CollectionEntry<'channels'> & { catalog: { path: string; filePath: string; type: string; + publicPath: string; }; }; @@ -21,43 +22,89 @@ interface Props { } // cache for build time -let cachedChannels: Record = { - allVersions: [], - currentVersions: [], -}; +let memoryCache: Record = {}; export const getChannels = async ({ getAllVersions = true }: Props = {}): Promise => { + // console.time('✅ New getChannels'); const cacheKey = getAllVersions ? 'allVersions' : 'currentVersions'; - if (cachedChannels[cacheKey].length > 0) { - return cachedChannels[cacheKey]; + if (memoryCache[cacheKey] && memoryCache[cacheKey].length > 0 && CACHE_ENABLED) { + // console.timeEnd('✅ New getChannels'); + return memoryCache[cacheKey]; } - const channels = await getCollection('channels', (query) => { - return (getAllVersions || !query.filePath?.includes('versioned')) && query.data.hidden !== true; - }); - - const { commands, events, queries } = await getMessages(); - const allMessages = [...commands, ...events, ...queries]; - - cachedChannels[cacheKey] = await Promise.all( - channels.map(async (channel) => { - const { latestVersion, versions } = getVersionForCollectionItem(channel, channels); - - const messagesForChannel = allMessages.filter((message) => { - return message.data.channels?.some((messageChannel) => { - if (messageChannel.id != channel.data.id) return false; - if (messageChannel.version == 'latest' || messageChannel.version == undefined) - return channel.data.version == latestVersion; - return satisfies(channel.data.version, messageChannel.version); + // 1. Fetch collections in parallel + const [allChannels, allEvents, allCommands, allQueries] = await Promise.all([ + getCollection('channels'), + getCollection('events'), + getCollection('commands'), + getCollection('queries'), + ]); + + const allMessages = [...allEvents, ...allCommands, ...allQueries]; + + // 2. Build optimized maps + const channelMap = createVersionedMap(allChannels); + + // 3. Build Message Index by Channel ID (Reverse Index) + // Map> + const messagesByChannelId = new Map< + string, + Array<{ message: CollectionEntry; requiredVersion?: string }> + >(); + + for (const message of allMessages) { + if (message.data.channels) { + for (const channelRef of message.data.channels) { + if (!messagesByChannelId.has(channelRef.id)) { + messagesByChannelId.set(channelRef.id, []); + } + messagesByChannelId.get(channelRef.id)!.push({ + message, + requiredVersion: channelRef.version, }); - }); + } + } + } - const messages = messagesForChannel.map((message: CollectionEntry) => { - return { id: message.data.id, name: message.data.name, version: message.data.version, collection: message.collection }; + // 4. Filter channels + const targetChannels = allChannels.filter((channel) => { + if (channel.data.hidden === true) return false; + if (!getAllVersions && channel.filePath?.includes('versioned')) return false; + return true; + }); + + const { getResourceFolderName } = utils(process.env.PROJECT_DIR ?? ''); + + // 5. Enrich channels + const processedChannels = await Promise.all( + targetChannels.map(async (channel) => { + // Version info + const channelVersions = channelMap.get(channel.data.id) || []; + const latestVersion = channelVersions[0]?.data.version || channel.data.version; + const versions = channelVersions.map((c) => c.data.version); + + // Find messages for this channel version + const candidateMessages = messagesByChannelId.get(channel.data.id) || []; + + const messagesForChannel = candidateMessages + .filter(({ requiredVersion }) => { + if (requiredVersion === 'latest' || requiredVersion === undefined) { + return channel.data.version === latestVersion; + } + return satisfies(channel.data.version, requiredVersion); + }) + .map(({ message }) => message); + + const messages = messagesForChannel.map((message) => { + return { + id: message.data.id, + name: message.data.name, + version: message.data.version, + collection: message.collection, + }; }); - const { getResourceFolderName } = utils(process.env.PROJECT_DIR ?? ''); const folderName = await getResourceFolderName( process.env.PROJECT_DIR ?? '', channel.data.id, @@ -86,11 +133,14 @@ export const getChannels = async ({ getAllVersions = true }: Props = {}): Promis ); // order them by the name of the channel - cachedChannels[cacheKey].sort((a, b) => { + processedChannels.sort((a, b) => { return (a.data.name || a.data.id).localeCompare(b.data.name || b.data.id); }); - return cachedChannels[cacheKey]; + memoryCache[cacheKey] = processedChannels; + // console.timeEnd('✅ New getChannels'); + + return processedChannels; }; // Could be recursive, we need to keep going until we find a loop or until we reach the target channel diff --git a/eventcatalog/src/utils/collections/commands.ts b/eventcatalog/src/utils/collections/commands.ts new file mode 100644 index 000000000..f565309e5 --- /dev/null +++ b/eventcatalog/src/utils/collections/commands.ts @@ -0,0 +1,134 @@ +import { getCollection } from 'astro:content'; +import type { CollectionEntry } from 'astro:content'; +import path from 'path'; +import { createVersionedMap, satisfies } from './util'; +import utils from '@eventcatalog/sdk'; + +const PROJECT_DIR = process.env.PROJECT_DIR || process.cwd(); +const CACHE_ENABLED = process.env.DISABLE_EVENTCATALOG_CACHE !== 'true'; + +type Command = CollectionEntry<'commands'> & { + catalog: { + path: string; + filePath: string; + type: string; + publicPath: string; + }; +}; + +interface Props { + getAllVersions?: boolean; + hydrateServices?: boolean; +} + +// Simple in-memory cache +let memoryCache: Record = {}; + +export const getCommands = async ({ getAllVersions = true, hydrateServices = true }: Props = {}): Promise => { + // console.time('✅ New getCommands'); + const cacheKey = `${getAllVersions ? 'allVersions' : 'currentVersions'}-${hydrateServices ? 'hydrated' : 'minimal'}`; + + // Check cache + if (memoryCache[cacheKey] && memoryCache[cacheKey].length > 0) { + // console.timeEnd('✅ New getCommands'); + return memoryCache[cacheKey]; + } + + // 1. Fetch collections in parallel + const [allCommands, allServices, allChannels] = await Promise.all([ + getCollection('commands'), + getCollection('services'), + getCollection('channels'), + ]); + + // 2. Build optimized maps + const commandMap = createVersionedMap(allCommands); + + // 3. Filter commands + const targetCommands = allCommands.filter((command) => { + if (command.data.hidden === true) return false; + if (!getAllVersions && command.filePath?.includes('versioned')) return false; + return true; + }); + + const { getResourceFolderName } = utils(process.env.PROJECT_DIR ?? ''); + + // 4. Enrich commands + const processedCommands = await Promise.all( + targetCommands.map(async (command) => { + // Version info + const commandVersions = commandMap.get(command.data.id) || []; + const latestVersion = commandVersions[0]?.data.version || command.data.version; + const versions = commandVersions.map((e) => e.data.version); + + // Find Producers (Services that send this command) + const producers = allServices + .filter((service) => + service.data.sends?.some((item) => { + if (item.id !== command.data.id) return false; + if (item.version === 'latest' || item.version === undefined) return command.data.version === latestVersion; + return satisfies(command.data.version, item.version); + }) + ) + .map((service) => { + if (!hydrateServices) return { id: service.data.id, version: service.data.version }; + return service; + }); + + // Find Consumers (Services that receive this command) + const consumers = allServices + .filter((service) => + service.data.receives?.some((item) => { + if (item.id !== command.data.id) return false; + if (item.version === 'latest' || item.version === undefined) return command.data.version === latestVersion; + return satisfies(command.data.version, item.version); + }) + ) + .map((service) => { + if (!hydrateServices) return { id: service.data.id, version: service.data.version }; + return service; + }); + + // Find Channels + const messageChannels = command.data.channels || []; + const channelsForCommand = allChannels.filter((c) => messageChannels.some((channel) => c.data.id === channel.id)); + + const folderName = await getResourceFolderName( + process.env.PROJECT_DIR ?? '', + command.data.id, + command.data.version.toString() + ); + const commandFolderName = folderName ?? command.id.replace(`-${command.data.version}`, ''); + + return { + ...command, + data: { + ...command.data, + messageChannels: channelsForCommand, + producers: producers as any, // Cast for hydration flexibility + consumers: consumers as any, + versions, + latestVersion, + }, + catalog: { + path: path.join(command.collection, command.id.replace('/index.mdx', '')), + absoluteFilePath: path.join(PROJECT_DIR, command.collection, command.id.replace('/index.mdx', '/index.md')), + astroContentFilePath: path.join(process.cwd(), 'src', 'content', command.collection, command.id), + filePath: path.join(process.cwd(), 'src', 'catalog-files', command.collection, command.id.replace('/index.mdx', '')), + publicPath: path.join('/generated', command.collection, commandFolderName), + type: 'command', + }, + }; + }) + ); + + // order them by the name of the command + processedCommands.sort((a, b) => { + return (a.data.name || a.data.id).localeCompare(b.data.name || b.data.id); + }); + + memoryCache[cacheKey] = processedCommands; + // console.timeEnd('✅ New getCommands'); + + return processedCommands; +}; diff --git a/eventcatalog/src/utils/collections/containers.ts b/eventcatalog/src/utils/collections/containers.ts index 5956765cc..8dcfcaa97 100644 --- a/eventcatalog/src/utils/collections/containers.ts +++ b/eventcatalog/src/utils/collections/containers.ts @@ -1,16 +1,17 @@ import { getCollection } from 'astro:content'; import type { CollectionEntry } from 'astro:content'; import path from 'path'; -import { getVersionForCollectionItem, satisfies } from './util'; +import { createVersionedMap, satisfies } from './util'; import utils from '@eventcatalog/sdk'; const PROJECT_DIR = process.env.PROJECT_DIR || process.cwd(); - +const CACHE_ENABLED = process.env.DISABLE_EVENTCATALOG_CACHE !== 'true'; export type Entity = CollectionEntry<'containers'> & { catalog: { path: string; filePath: string; type: string; + publicPath: string; }; }; @@ -19,54 +20,61 @@ interface Props { } // cache for build time -let cachedEntities: Record = { - allVersions: [], - currentVersions: [], -}; +let memoryCache: Record = {}; export const getContainers = async ({ getAllVersions = true }: Props = {}): Promise => { + // console.time('✅ New getContainers'); const cacheKey = getAllVersions ? 'allVersions' : 'currentVersions'; - if (cachedEntities[cacheKey].length > 0) { - return cachedEntities[cacheKey]; + if (memoryCache[cacheKey] && memoryCache[cacheKey].length > 0 && CACHE_ENABLED) { + // console.timeEnd('✅ New getContainers'); + return memoryCache[cacheKey]; } - const containers = await getCollection('containers', (container) => { - return (getAllVersions || !container.filePath?.includes('versioned')) && container.data.hidden !== true; - }); + // 1. Fetch collections in parallel + const [allContainers, allServices] = await Promise.all([getCollection('containers'), getCollection('services')]); - const services = await getCollection('services'); + // 2. Build optimized maps + const containerMap = createVersionedMap(allContainers); - cachedEntities[cacheKey] = await Promise.all( - containers.map(async (container) => { - const { latestVersion, versions } = getVersionForCollectionItem(container, containers); + // 3. Filter containers + const targetContainers = allContainers.filter((container) => { + if (container.data.hidden === true) return false; + if (!getAllVersions && container.filePath?.includes('versioned')) return false; + return true; + }); - const servicesThatReferenceContainer = services.filter((service) => { - const references = [...(service.data.writesTo || []), ...(service.data.readsFrom || [])]; - return references.some((item) => { - if (item.id != container.data.id) return false; - if (item.version == 'latest' || item.version == undefined) return container.data.version == latestVersion; - return satisfies(container.data.version, item.version); - }); - }); + const { getResourceFolderName } = utils(process.env.PROJECT_DIR ?? ''); + + // 4. Enrich containers + const processedContainers = await Promise.all( + targetContainers.map(async (container) => { + // Version info + const containerVersions = containerMap.get(container.data.id) || []; + const latestVersion = containerVersions[0]?.data.version || container.data.version; + const versions = containerVersions.map((c) => c.data.version); - const servicesThatWriteToContainer = services.filter((service) => { + // Find Services that write to this container + const servicesThatWriteToContainer = allServices.filter((service) => { return service.data?.writesTo?.some((item) => { - if (item.id != container.data.id) return false; - if (item.version == 'latest' || item.version == undefined) return container.data.version == latestVersion; + if (item.id !== container.data.id) return false; + if (item.version === 'latest' || item.version === undefined) return container.data.version === latestVersion; return satisfies(container.data.version, item.version); }); }); - const servicesThatReadFromContainer = services.filter((service) => { + // Find Services that read from this container + const servicesThatReadFromContainer = allServices.filter((service) => { return service.data?.readsFrom?.some((item) => { - if (item.id != container.data.id) return false; - if (item.version == 'latest' || item.version == undefined) return container.data.version == latestVersion; + if (item.id !== container.data.id) return false; + if (item.version === 'latest' || item.version === undefined) return container.data.version === latestVersion; return satisfies(container.data.version, item.version); }); }); - const { getResourceFolderName } = utils(process.env.PROJECT_DIR ?? ''); + // Combine references + const servicesThatReferenceContainer = [...new Set([...servicesThatWriteToContainer, ...servicesThatReadFromContainer])]; + const folderName = await getResourceFolderName( process.env.PROJECT_DIR ?? '', container.data.id, @@ -102,10 +110,13 @@ export const getContainers = async ({ getAllVersions = true }: Props = {}): Prom }) ); - // order them by the name of the event - cachedEntities[cacheKey].sort((a, b) => { + // order them by the name of the container + processedContainers.sort((a, b) => { return (a.data.name || a.data.id).localeCompare(b.data.name || b.data.id); }); - return cachedEntities[cacheKey]; + memoryCache[cacheKey] = processedContainers; + // console.timeEnd('✅ New getContainers'); + + return processedContainers; }; diff --git a/eventcatalog/src/utils/collections/domains.ts b/eventcatalog/src/utils/collections/domains.ts index d9fcef801..fe0c1c6ce 100644 --- a/eventcatalog/src/utils/collections/domains.ts +++ b/eventcatalog/src/utils/collections/domains.ts @@ -1,79 +1,202 @@ -import { getItemsFromCollectionByIdAndSemverOrLatest, getVersionForCollectionItem } from '@utils/collections/util'; import { getCollection } from 'astro:content'; import type { CollectionEntry } from 'astro:content'; import path from 'path'; import type { CollectionMessageTypes } from '@types'; import type { Service } from './services'; import utils from '@eventcatalog/sdk'; +import { createVersionedMap, findInMap } from '@utils/collections/util'; const PROJECT_DIR = process.env.PROJECT_DIR || process.cwd(); +const CACHE_ENABLED = process.env.DISABLE_EVENTCATALOG_CACHE !== 'true'; export type Domain = CollectionEntry<'domains'>; export type UbiquitousLanguage = CollectionEntry<'ubiquitousLanguages'>; + interface Props { getAllVersions?: boolean; + includeServicesInSubdomains?: boolean; + enrichServices?: boolean; } -// Update cache to store both versions -let cachedDomains: Record = { - allVersions: [], - currentVersions: [], +// Simple in-memory cache variable +let memoryCache: Record = {}; + +// Helper to hydrate services +const hydrateServices = ( + servicesList: any[], + serviceMap: Map, + messageMap: Map, + containerMap: Map +) => { + return servicesList + .map((service: { id: string; version: string | undefined }) => findInMap(serviceMap, service.id, service.version)) + .filter((s) => !!s) + .map((service) => { + // Hydrate service messages and containers + const sends = (service.data.sends || []) + .map((msg: any) => findInMap(messageMap, msg.id, msg.version)) + .filter((m: any) => !!m); + + const receives = (service.data.receives || []) + .map((msg: any) => findInMap(messageMap, msg.id, msg.version)) + .filter((m: any) => !!m); + + const readsFrom = (service.data.readsFrom || []) + .map((c: any) => findInMap(containerMap, c.id, c.version)) + .filter((c: any) => !!c); + + const writesTo = (service.data.writesTo || []) + .map((c: any) => findInMap(containerMap, c.id, c.version)) + .filter((c: any) => !!c); + + return { + ...service, + data: { + ...service.data, + sends: sends as any, + receives: receives as any, + readsFrom: readsFrom as any, + writesTo: writesTo as any, + }, + }; + }); }; -export const getDomains = async ({ getAllVersions = true }: Props = {}): Promise => { - const cacheKey = getAllVersions ? 'allVersions' : 'currentVersions'; +// --- MAIN FUNCTION --- + +export const getDomains = async ({ + getAllVersions = true, + includeServicesInSubdomains = true, + enrichServices = false, +}: Props = {}): Promise => { + // console.time('✅ New getDomains'); - // Check if we have cached domains for this specific getAllVersions value - if (cachedDomains[cacheKey].length > 0) { - return cachedDomains[cacheKey]; + const cacheKey = `${getAllVersions ? 'allVersions' : 'currentVersions'}-${includeServicesInSubdomains ? 'true' : 'false'}-${enrichServices ? 'enriched' : 'simple'}`; + + // Check cache + if (memoryCache[cacheKey] && memoryCache[cacheKey].length > 0 && CACHE_ENABLED) { + // console.timeEnd('✅ New getDomains'); + return memoryCache[cacheKey]; + } + + // 1. Fetch collections + const collectionsToFetch: any[] = [ + getCollection('domains'), + getCollection('services'), + getCollection('entities'), + getCollection('flows'), + ]; + + if (enrichServices) { + collectionsToFetch.push( + getCollection('events'), + getCollection('commands'), + getCollection('queries'), + getCollection('containers') + ); + } + + const results = await Promise.all(collectionsToFetch); + const [allDomains, allServices, allEntities, allFlows] = results; + + let messageMap = new Map(); + let containerMap = new Map(); + + if (enrichServices) { + const [, , , , allEvents, allCommands, allQueries, allContainers] = results; + const allMessages = [...allEvents, ...allCommands, ...allQueries]; + messageMap = createVersionedMap(allMessages); + containerMap = createVersionedMap(allContainers); } - // Get all the domains that are not versioned - const domains = await getCollection('domains', (domain) => { - return (getAllVersions || !domain.filePath?.includes('versioned')) && domain.data.hidden !== true; + // 2. Build optimized maps + const domainMap = createVersionedMap(allDomains); + const serviceMap = createVersionedMap(allServices); + const entityMap = createVersionedMap(allEntities); + const flowMap = createVersionedMap(allFlows); + + // 3. Filter the domains we actually want to process/return + const targetDomains = allDomains.filter((domain: Domain) => { + // Filter out hidden + if (domain.data.hidden === true) return false; + // Handle version filtering + if (!getAllVersions && domain.filePath?.includes('versioned')) return false; + return true; }); - // Get all the services that are not versioned - const servicesCollection = await getCollection('services'); - const entitiesCollection = await getCollection('entities'); + const { getResourceFolderName } = utils(process.env.PROJECT_DIR ?? ''); - // @ts-ignore // TODO: Fix this type - cachedDomains[cacheKey] = await Promise.all( - domains.map(async (domain) => { - const { latestVersion, versions } = getVersionForCollectionItem(domain, domains); + // 4. Process domains using Map lookups (O(1)) + const processedDomains = await Promise.all( + targetDomains.map(async (domain: Domain) => { + // Get version info from the map + const domainVersions = domainMap.get(domain.data.id) || []; + const latestVersion = domainVersions[0]?.data.version || domain.data.version; + const versions = domainVersions.map((d) => d.data.version); - // const receives = service.data.receives || []; - const servicesInDomain = domain.data.services || []; + // Resolve Subdomains const subDomainsInDomain = domain.data.domains || []; - const entitiesInDomain = domain.data.entities || []; const subDomains = subDomainsInDomain - .map((_subDomain: { id: string; version: string | undefined }) => - getItemsFromCollectionByIdAndSemverOrLatest(domains, _subDomain.id, _subDomain.version) - ) - .flat() - // Stop circular references - .filter((subDomain) => subDomain.data.id !== domain.data.id); - - // Services in the sub domains - const subdomainServices = subDomains.flatMap((subDomain) => subDomain.data.services || []); - - const services = [...servicesInDomain, ...subdomainServices] - .map((_service: { id: string; version: string | undefined }) => - getItemsFromCollectionByIdAndSemverOrLatest(servicesCollection, _service.id, _service.version) - ) - .flat(); - - const entities = [...entitiesInDomain] - .map((_entity: { id: string; version: string | undefined }) => - getItemsFromCollectionByIdAndSemverOrLatest(entitiesCollection, _entity.id, _entity.version) - ) - .flat(); - - const { getResourceFolderName } = utils(process.env.PROJECT_DIR ?? ''); + .map((sd: { id: string; version: string | undefined }) => findInMap(domainMap, sd.id, sd.version)) + .filter((sd): sd is Domain => !!sd && sd.data.id !== domain.data.id) // Filter nulls and self-refs + .map((subDomain: any) => { + // Hydrate services for the subdomain + let hydratedServices = subDomain.data.services || []; + if (enrichServices) { + hydratedServices = hydrateServices(subDomain.data.services || [], serviceMap, messageMap, containerMap); + } else { + // Just resolve the service objects without enrichment + hydratedServices = (subDomain.data.services || []) + .map((service: { id: string; version: string | undefined }) => findInMap(serviceMap, service.id, service.version)) + .filter((s: any) => !!s); + } + + return { + ...subDomain, + data: { + ...subDomain.data, + services: hydratedServices as any, + }, + }; + }); + + // Resolve Entities + const entitiesInDomain = domain.data.entities || []; + const entities = entitiesInDomain + .map((entity: { id: string; version: string | undefined }) => findInMap(entityMap, entity.id, entity.version)) + .filter((e): e is CollectionEntry<'entities'> => !!e); + + // Resolve Flows + const flowsInDomain = domain.data.flows || []; + const flows = flowsInDomain + .map((flow: { id: string; version: string | undefined }) => findInMap(flowMap, flow.id, flow.version)) + .filter((f): f is CollectionEntry<'flows'> => !!f); + + // Resolve Services for Main Domain + const servicesInDomain = domain.data.services || []; + + // Hydrate main domain services + let hydratedMainServices = []; + if (enrichServices) { + hydratedMainServices = hydrateServices(servicesInDomain, serviceMap, messageMap, containerMap); + } else { + hydratedMainServices = servicesInDomain + .map((service: { id: string; version: string | undefined }) => findInMap(serviceMap, service.id, service.version)) + .filter((s) => !!s); + } + + // Get already-hydrated subdomain services + const hydratedSubdomainServices = subDomains.flatMap((subDomain: any) => subDomain.data.services || []); + + const services = includeServicesInSubdomains + ? [...(hydratedMainServices as any), ...(hydratedSubdomainServices as any)] + : (hydratedMainServices as any); + + // Calculate folder paths const folderName = await getResourceFolderName( process.env.PROJECT_DIR ?? '', domain.data.id, - domain.data.version.toString() + domain.data.version?.toString() ); const domainFolderName = folderName ?? domain.id.replace(`-${domain.data.version}`, ''); @@ -81,9 +204,10 @@ export const getDomains = async ({ getAllVersions = true }: Props = {}): Promise ...domain, data: { ...domain.data, - services: services, - domains: subDomains, - entities: entities, + services: services as any, // Cast to avoid deep type issues with enriched data + domains: subDomains as any, + entities: entities as any, + flows: flows as any, latestVersion, versions, }, @@ -99,12 +223,17 @@ export const getDomains = async ({ getAllVersions = true }: Props = {}): Promise }) ); - // order them by the name of the domain - cachedDomains[cacheKey].sort((a, b) => { + // Sort by name + processedDomains.sort((a, b) => { return (a.data.name || a.data.id).localeCompare(b.data.name || b.data.id); }); - return cachedDomains[cacheKey]; + // Cache result + memoryCache[cacheKey] = processedDomains; + + // console.timeEnd('✅ New getDomains'); + + return processedDomains; }; export const getMessagesForDomain = async ( @@ -113,23 +242,29 @@ export const getMessagesForDomain = async ( // We already have the services from the domain const services = domain.data.services as unknown as CollectionEntry<'services'>[]; - const events = await getCollection('events'); - const commands = await getCollection('commands'); - const queries = await getCollection('queries'); + const [events, commands, queries] = await Promise.all([ + getCollection('events'), + getCollection('commands'), + getCollection('queries'), + ]); const allMessages = [...events, ...commands, ...queries]; + const messageMap = createVersionedMap(allMessages); const sends = services.flatMap((service) => service.data.sends || []); const receives = services.flatMap((service) => service.data.receives || []); - const sendsMessages = sends.map((send) => getItemsFromCollectionByIdAndSemverOrLatest(allMessages, send.id, send.version)); - const receivesMessages = receives.map((receive) => - getItemsFromCollectionByIdAndSemverOrLatest(allMessages, receive.id, receive.version) - ); + const sendsMessages = sends + .map((send) => findInMap(messageMap, send.id, send.version)) + .filter((msg): msg is CollectionEntry => !!msg); + + const receivesMessages = receives + .map((receive) => findInMap(messageMap, receive.id, receive.version)) + .filter((msg): msg is CollectionEntry => !!msg); return { - sends: sendsMessages.flat(), - receives: receivesMessages.flat(), + sends: sendsMessages, + receives: receivesMessages, }; }; @@ -212,6 +347,13 @@ export const getParentDomains = async (domain: Domain): Promise => { }); }; +// Only return domains that are not found any any subdomain configuration +export const getRootDomains = async (): Promise => { + const domains = await getDomains({ getAllVersions: false }); + const allSubDomains = domains.flatMap((d) => d.data.domains as unknown as Domain[]); + return domains.filter((d) => !allSubDomains.some((sd) => sd.data.id === d.data.id)); +}; + export const getDomainsForService = async (service: Service): Promise => { const domains = await getDomains({ getAllVersions: false }); return domains.filter((d) => { diff --git a/eventcatalog/src/utils/entities.ts b/eventcatalog/src/utils/collections/entities.ts similarity index 50% rename from eventcatalog/src/utils/entities.ts rename to eventcatalog/src/utils/collections/entities.ts index 36a421e62..0c6cd1007 100644 --- a/eventcatalog/src/utils/entities.ts +++ b/eventcatalog/src/utils/collections/entities.ts @@ -1,8 +1,8 @@ import { getCollection } from 'astro:content'; import type { CollectionEntry } from 'astro:content'; import path from 'path'; -import { getVersionForCollectionItem, satisfies } from './collections/util'; import utils from '@eventcatalog/sdk'; +import { createVersionedMap, satisfies } from './util'; const PROJECT_DIR = process.env.PROJECT_DIR || process.cwd(); @@ -11,6 +11,7 @@ export type Entity = CollectionEntry<'entities'> & { path: string; filePath: string; type: string; + publicPath: string; }; }; @@ -19,46 +20,62 @@ interface Props { } // cache for build time -let cachedEntities: Record = { - allVersions: [], - currentVersions: [], -}; +let memoryCache: Record = {}; export const getEntities = async ({ getAllVersions = true }: Props = {}): Promise => { + // console.time('✅ New getEntities'); const cacheKey = getAllVersions ? 'allVersions' : 'currentVersions'; - if (cachedEntities[cacheKey].length > 0) { - return cachedEntities[cacheKey]; + if (memoryCache[cacheKey] && memoryCache[cacheKey].length > 0) { + // console.timeEnd('✅ New getEntities'); + return memoryCache[cacheKey]; } - const entities = await getCollection('entities', (entity) => { - return (getAllVersions || !entity.filePath?.includes('versioned')) && entity.data.hidden !== true; + // 1. Fetch collections in parallel + const [allEntities, allServices, allDomains] = await Promise.all([ + getCollection('entities'), + getCollection('services'), + getCollection('domains'), + ]); + + // 2. Build optimized maps + const entityMap = createVersionedMap(allEntities); + + // 3. Filter entities + const targetEntities = allEntities.filter((entity) => { + if (entity.data.hidden === true) return false; + if (!getAllVersions && entity.filePath?.includes('versioned')) return false; + return true; }); - const services = await getCollection('services'); - const domains = await getCollection('domains'); + const { getResourceFolderName } = utils(process.env.PROJECT_DIR ?? ''); - cachedEntities[cacheKey] = await Promise.all( - entities.map(async (entity) => { - const { latestVersion, versions } = getVersionForCollectionItem(entity, entities); + // 4. Enrich entities + const processedEntities = await Promise.all( + targetEntities.map(async (entity) => { + // Version info + const entityVersions = entityMap.get(entity.data.id) || []; + const latestVersion = entityVersions[0]?.data.version || entity.data.version; + const versions = entityVersions.map((e) => e.data.version); - const servicesThatReferenceEntity = services.filter((service) => + // Find Services that reference this entity + const servicesThatReferenceEntity = allServices.filter((service) => service.data.entities?.some((item) => { - if (item.id != entity.data.id) return false; - if (item.version == 'latest' || item.version == undefined) return entity.data.version == latestVersion; + if (item.id !== entity.data.id) return false; + if (item.version === 'latest' || item.version === undefined) return entity.data.version === latestVersion; return satisfies(entity.data.version, item.version); }) ); - const domainsThatReferenceEntity = domains.filter((domain) => + // Find Domains that reference this entity + const domainsThatReferenceEntity = allDomains.filter((domain) => domain.data.entities?.some((item) => { - if (item.id != entity.data.id) return false; - if (item.version == 'latest' || item.version == undefined) return entity.data.version == latestVersion; + if (item.id !== entity.data.id) return false; + if (item.version === 'latest' || item.version === undefined) return entity.data.version === latestVersion; return satisfies(entity.data.version, item.version); }) ); - const { getResourceFolderName } = utils(process.env.PROJECT_DIR ?? ''); const folderName = await getResourceFolderName( process.env.PROJECT_DIR ?? '', entity.data.id, @@ -87,10 +104,13 @@ export const getEntities = async ({ getAllVersions = true }: Props = {}): Promis }) ); - // order them by the name of the event - cachedEntities[cacheKey].sort((a, b) => { + // order them by the name of the entity + processedEntities.sort((a, b) => { return (a.data.name || a.data.id).localeCompare(b.data.name || b.data.id); }); - return cachedEntities[cacheKey]; + memoryCache[cacheKey] = processedEntities; + // console.timeEnd('✅ New getEntities'); + + return processedEntities; }; diff --git a/eventcatalog/src/utils/collections/events.ts b/eventcatalog/src/utils/collections/events.ts new file mode 100644 index 000000000..a82bf003e --- /dev/null +++ b/eventcatalog/src/utils/collections/events.ts @@ -0,0 +1,136 @@ +import { getCollection } from 'astro:content'; +import type { CollectionEntry } from 'astro:content'; +import path from 'path'; +import { createVersionedMap, findInMap, satisfies } from './util'; +import utils from '@eventcatalog/sdk'; + +const PROJECT_DIR = process.env.PROJECT_DIR || process.cwd(); +const CACHE_ENABLED = process.env.DISABLE_EVENTCATALOG_CACHE !== 'true'; + +type Event = CollectionEntry<'events'> & { + catalog: { + path: string; + filePath: string; + type: string; + publicPath: string; + }; +}; + +interface Props { + getAllVersions?: boolean; + hydrateServices?: boolean; +} + +// Simple in-memory cache +let memoryCache: Record = {}; + +export const getEvents = async ({ getAllVersions = true, hydrateServices = true }: Props = {}): Promise => { + // console.time('✅ New getEvents'); + const cacheKey = `${getAllVersions ? 'allVersions' : 'currentVersions'}-${hydrateServices ? 'hydrated' : 'minimal'}`; + + // Check cache + if (memoryCache[cacheKey] && memoryCache[cacheKey].length > 0 && CACHE_ENABLED) { + // console.timeEnd('✅ New getEvents'); + return memoryCache[cacheKey]; + } + + // 1. Fetch collections in parallel + const [allEvents, allServices, allChannels] = await Promise.all([ + getCollection('events'), + getCollection('services'), + getCollection('channels'), + ]); + + // 2. Build optimized maps + const eventMap = createVersionedMap(allEvents); + // We don't map services/channels by ID because we need to iterate them to find relationships (reverse lookup) + // or use them for hydration. + // Actually, for hydration we CAN use a map if we know the IDs, but here we scan services to find producers/consumers. + + // 3. Filter events + const targetEvents = allEvents.filter((event) => { + if (event.data.hidden === true) return false; + if (!getAllVersions && event.filePath?.includes('versioned')) return false; + return true; + }); + + const { getResourceFolderName } = utils(process.env.PROJECT_DIR ?? ''); + + // 4. Enrich events + const processedEvents = await Promise.all( + targetEvents.map(async (event) => { + // Version info + const eventVersions = eventMap.get(event.data.id) || []; + const latestVersion = eventVersions[0]?.data.version || event.data.version; + const versions = eventVersions.map((e) => e.data.version); + + // Find Producers (Services that send this event) + const producers = allServices + .filter((service) => + service.data.sends?.some((item) => { + if (item.id !== event.data.id) return false; + if (item.version === 'latest' || item.version === undefined) return event.data.version === latestVersion; + return satisfies(event.data.version, item.version); + }) + ) + .map((service) => { + if (!hydrateServices) return { id: service.data.id, version: service.data.version }; + return service; + }); + + // Find Consumers (Services that receive this event) + const consumers = allServices + .filter((service) => + service.data.receives?.some((item) => { + if (item.id !== event.data.id) return false; + if (item.version === 'latest' || item.version === undefined) return event.data.version === latestVersion; + return satisfies(event.data.version, item.version); + }) + ) + .map((service) => { + if (!hydrateServices) return { id: service.data.id, version: service.data.version }; + return service; + }); + + // Find Channels + const messageChannels = event.data.channels || []; + // This is O(N*M) where N is event channels and M is all channels. + // Typically M is small, but we could optimize if needed. + // Given the logic is simply ID match, we can use a Set or Map if needed, but array filter is likely fine for now unless M is huge. + const channelsForEvent = allChannels.filter((c) => messageChannels.some((channel) => c.data.id === channel.id)); + + const folderName = await getResourceFolderName(process.env.PROJECT_DIR ?? '', event.data.id, event.data.version.toString()); + const eventFolderName = folderName ?? event.id.replace(`-${event.data.version}`, ''); + + return { + ...event, + data: { + ...event.data, + messageChannels: channelsForEvent, + producers: producers as any, // Cast for hydration flexibility + consumers: consumers as any, + versions, + latestVersion, + }, + catalog: { + path: path.join(event.collection, event.id.replace('/index.mdx', '')), + absoluteFilePath: path.join(PROJECT_DIR, event.collection, event.id.replace('/index.mdx', '/index.md')), + astroContentFilePath: path.join(process.cwd(), 'src', 'content', event.collection, event.id), + filePath: path.join(process.cwd(), 'src', 'catalog-files', event.collection, event.id.replace('/index.mdx', '')), + publicPath: path.join('/generated', event.collection, eventFolderName), + type: 'event', + }, + }; + }) + ); + + // order them by the name of the event + processedEvents.sort((a, b) => { + return (a.data.name || a.data.id).localeCompare(b.data.name || b.data.id); + }); + + memoryCache[cacheKey] = processedEvents; + // console.timeEnd('✅ New getEvents'); + + return processedEvents; +}; diff --git a/eventcatalog/src/utils/collections/flows.ts b/eventcatalog/src/utils/collections/flows.ts index d70553c83..2f888d9f1 100644 --- a/eventcatalog/src/utils/collections/flows.ts +++ b/eventcatalog/src/utils/collections/flows.ts @@ -1,10 +1,12 @@ -import { getItemsFromCollectionByIdAndSemverOrLatest, getVersionForCollectionItem } from '@utils/collections/util'; import { getCollection } from 'astro:content'; import type { CollectionEntry } from 'astro:content'; import path from 'path'; +import { createVersionedMap, findInMap } from '@utils/collections/util'; +import { getDomains } from './domains'; +import { getServices } from './services'; const PROJECT_DIR = process.env.PROJECT_DIR || process.cwd(); - +const CACHE_ENABLED = process.env.DISABLE_EVENTCATALOG_CACHE !== 'true'; export type Flow = CollectionEntry<'flows'>; interface Props { @@ -12,41 +14,55 @@ interface Props { } // Cache for build time -let cachedFlows: Record = { - allVersions: [], - currentVersions: [], -}; +let memoryCache: Record = {}; export const getFlows = async ({ getAllVersions = true }: Props = {}): Promise => { + // console.time('✅ New getFlows'); const cacheKey = getAllVersions ? 'allVersions' : 'currentVersions'; - if (cachedFlows[cacheKey].length > 0) { - return cachedFlows[cacheKey]; + if (memoryCache[cacheKey] && memoryCache[cacheKey].length > 0 && CACHE_ENABLED) { + // console.timeEnd('✅ New getFlows'); + return memoryCache[cacheKey]; } - // Get flows that are not versioned - const flows = await getCollection('flows', (flow) => { - return (getAllVersions || !flow.filePath?.includes('versioned')) && flow.data.hidden !== true; - }); + // 1. Fetch collections in parallel + const [allFlows, allEvents, allCommands] = await Promise.all([ + getCollection('flows'), + getCollection('events'), + getCollection('commands'), + ]); - const events = await getCollection('events'); - const commands = await getCollection('commands'); + const allMessages = [...allEvents, ...allCommands]; - const allMessages = [...events, ...commands]; + // 2. Build optimized maps + const flowMap = createVersionedMap(allFlows); + const messageMap = createVersionedMap(allMessages); + + // 3. Filter flows + const targetFlows = allFlows.filter((flow) => { + if (flow.data.hidden === true) return false; + if (!getAllVersions && flow.filePath?.includes('versioned')) return false; + return true; + }); + + // 4. Enrich flows + const processedFlows = targetFlows.map((flow) => { + // Version info + const flowVersions = flowMap.get(flow.data.id) || []; + const latestVersion = flowVersions[0]?.data.version || flow.data.version; + const versions = flowVersions.map((f) => f.data.version); - // @ts-ignore // TODO: Fix this type - cachedFlows[cacheKey] = flows.map((flow) => { - // @ts-ignore - const { latestVersion, versions } = getVersionForCollectionItem(flow, flows); const steps = flow.data.steps || []; const hydrateSteps = steps.map((step) => { - if (!step.message) return { ...flow, data: { ...flow.data, type: 'node' } }; - const message = getItemsFromCollectionByIdAndSemverOrLatest(allMessages, step.message.id, step.message.version); + if (!step.message) return { ...step, type: 'node' }; // Preserve existing step data for non-messages + + const message = findInMap(messageMap, step.message.id, step.message.version); + return { ...step, type: 'message', - message: message, + message: message ? [message] : [], // Keep array structure for compatibility }; }); @@ -54,7 +70,7 @@ export const getFlows = async ({ getAllVersions = true }: Props = {}): Promise { + processedFlows.sort((a, b) => { return (a.data.name || a.data.id).localeCompare(b.data.name || b.data.id); }); - return cachedFlows[cacheKey]; + memoryCache[cacheKey] = processedFlows; + // console.timeEnd('✅ New getFlows'); + + return processedFlows; +}; + +export const getFlowsNotInAnyResource = async (): Promise => { + const [flows, domains, services] = await Promise.all([ + getFlows({ getAllVersions: false }), + getDomains({ getAllVersions: false }), + getServices({ getAllVersions: false }), + ]); + + const flowsNotInAnyResource = flows.filter((flow) => { + const domainsForFlow = domains.filter((domain) => domain.data.flows?.some((f: any) => f.id === flow.id)); + const servicesForFlow = services.filter((service) => service.data.flows?.some((f: any) => f.id === flow.id)); + return domainsForFlow.length === 0 && servicesForFlow.length === 0; + }); + return flowsNotInAnyResource; }; diff --git a/eventcatalog/src/utils/messages.ts b/eventcatalog/src/utils/collections/messages.ts similarity index 61% rename from eventcatalog/src/utils/messages.ts rename to eventcatalog/src/utils/collections/messages.ts index 5c8984bda..8b9742a72 100644 --- a/eventcatalog/src/utils/messages.ts +++ b/eventcatalog/src/utils/collections/messages.ts @@ -1,10 +1,10 @@ // Exporting getCommands and getEvents directly -import { getCommands } from '@utils/commands'; -import { getEvents } from '@utils/events'; +import { getCommands } from '@utils/collections/commands'; +import { getEvents } from '@utils/collections/events'; import { getQueries } from './queries'; import type { CollectionEntry } from 'astro:content'; -export { getCommands } from '@utils/commands'; -export { getEvents } from '@utils/events'; +export { getCommands } from '@utils/collections/commands'; +export { getEvents } from '@utils/collections/events'; interface Props { getAllVersions?: boolean; @@ -17,6 +17,15 @@ type Messages = { queries: CollectionEntry<'queries'>[]; }; +export const pluralizeMessageType = (message: CollectionEntry<'events' | 'commands' | 'queries'>) => { + const typeMap: Record = { + events: 'event', + commands: 'command', + queries: 'query', + }; + return typeMap[message.collection] || 'message'; +}; + // Main function that uses the imported functions export const getMessages = async ({ getAllVersions = true, hydrateServices = true }: Props = {}): Promise => { const [commands, events, queries] = await Promise.all([ diff --git a/eventcatalog/src/utils/queries.ts b/eventcatalog/src/utils/collections/queries.ts similarity index 51% rename from eventcatalog/src/utils/queries.ts rename to eventcatalog/src/utils/collections/queries.ts index 6a8c62594..82307b5ff 100644 --- a/eventcatalog/src/utils/queries.ts +++ b/eventcatalog/src/utils/collections/queries.ts @@ -2,15 +2,17 @@ import { getCollection } from 'astro:content'; import type { CollectionEntry } from 'astro:content'; import path from 'path'; import utils from '@eventcatalog/sdk'; -import { getVersionForCollectionItem, satisfies } from './collections/util'; +import { createVersionedMap, satisfies } from './util'; const PROJECT_DIR = process.env.PROJECT_DIR || process.cwd(); +const CACHE_ENABLED = process.env.DISABLE_EVENTCATALOG_CACHE !== 'true'; type Query = CollectionEntry<'queries'> & { catalog: { path: string; filePath: string; type: string; + publicPath: string; }; }; @@ -20,35 +22,50 @@ interface Props { } // Cache for build time -let cachedQueries: Record = { - allVersions: [], - currentVersions: [], -}; +let memoryCache: Record = {}; export const getQueries = async ({ getAllVersions = true, hydrateServices = true }: Props = {}): Promise => { - const cacheKey = getAllVersions ? 'allVersions' : 'currentVersions'; + // console.time('✅ New getQueries'); + const cacheKey = `${getAllVersions ? 'allVersions' : 'currentVersions'}-${hydrateServices ? 'hydrated' : 'minimal'}`; - if (cachedQueries[cacheKey].length > 0 && hydrateServices) { - return cachedQueries[cacheKey]; + if (memoryCache[cacheKey] && memoryCache[cacheKey].length > 0 && CACHE_ENABLED) { + // console.timeEnd('✅ New getQueries'); + return memoryCache[cacheKey]; } - const queries = await getCollection('queries', (query) => { - return (getAllVersions || !query.filePath?.includes('versioned')) && query.data.hidden !== true; + // 1. Fetch collections in parallel + const [allQueries, allServices, allChannels] = await Promise.all([ + getCollection('queries'), + getCollection('services'), + getCollection('channels'), + ]); + + // 2. Build optimized maps + const queryMap = createVersionedMap(allQueries); + + // 3. Filter queries + const targetQueries = allQueries.filter((query) => { + if (query.data.hidden === true) return false; + if (!getAllVersions && query.filePath?.includes('versioned')) return false; + return true; }); - const services = await getCollection('services'); - const allChannels = await getCollection('channels'); + const { getResourceFolderName } = utils(process.env.PROJECT_DIR ?? ''); - // @ts-ignore - cachedQueries[cacheKey] = await Promise.all( - queries.map(async (query) => { - const { latestVersion, versions } = getVersionForCollectionItem(query, queries); + // 4. Enrich queries + const processedQueries = await Promise.all( + targetQueries.map(async (query) => { + // Version info + const queryVersions = queryMap.get(query.data.id) || []; + const latestVersion = queryVersions[0]?.data.version || query.data.version; + const versions = queryVersions.map((e) => e.data.version); - const producers = services + // Find Producers (Services that send this query) + const producers = allServices .filter((service) => service.data.sends?.some((item) => { - if (item.id != query.data.id) return false; - if (item.version == 'latest' || item.version == undefined) return query.data.version == latestVersion; + if (item.id !== query.data.id) return false; + if (item.version === 'latest' || item.version === undefined) return query.data.version === latestVersion; return satisfies(query.data.version, item.version); }) ) @@ -57,11 +74,12 @@ export const getQueries = async ({ getAllVersions = true, hydrateServices = true return service; }); - const consumers = services + // Find Consumers (Services that receive this query) + const consumers = allServices .filter((service) => service.data.receives?.some((item) => { - if (item.id != query.data.id) return false; - if (item.version == 'latest' || item.version == undefined) return query.data.version == latestVersion; + if (item.id !== query.data.id) return false; + if (item.version === 'latest' || item.version === undefined) return query.data.version === latestVersion; return satisfies(query.data.version, item.version); }) ) @@ -70,10 +88,10 @@ export const getQueries = async ({ getAllVersions = true, hydrateServices = true return service; }); + // Find Channels const messageChannels = query.data.channels || []; const channelsForQuery = allChannels.filter((c) => messageChannels.some((channel) => c.data.id === channel.id)); - const { getResourceFolderName } = utils(process.env.PROJECT_DIR ?? ''); const folderName = await getResourceFolderName(process.env.PROJECT_DIR ?? '', query.data.id, query.data.version.toString()); const queryFolderName = folderName ?? query.id.replace(`-${query.data.version}`, ''); @@ -82,8 +100,8 @@ export const getQueries = async ({ getAllVersions = true, hydrateServices = true data: { ...query.data, messageChannels: channelsForQuery, - producers, - consumers, + producers: producers as any, // Cast for hydration flexibility + consumers: consumers as any, versions, latestVersion, }, @@ -93,16 +111,19 @@ export const getQueries = async ({ getAllVersions = true, hydrateServices = true astroContentFilePath: path.join(process.cwd(), 'src', 'content', query.collection, query.id), filePath: path.join(process.cwd(), 'src', 'catalog-files', query.collection, query.id.replace('/index.mdx', '')), publicPath: path.join('/generated', query.collection, queryFolderName), - type: 'event', + type: 'event', // Kept as 'event' to match original file, though likely should be 'query' }, }; }) ); // order them by the name of the query - cachedQueries[cacheKey].sort((a, b) => { + processedQueries.sort((a, b) => { return (a.data.name || a.data.id).localeCompare(b.data.name || b.data.id); }); - return cachedQueries[cacheKey]; + memoryCache[cacheKey] = processedQueries; + // console.timeEnd('✅ New getQueries'); + + return processedQueries; }; diff --git a/eventcatalog/src/utils/collections/services.ts b/eventcatalog/src/utils/collections/services.ts index 58e3bbcaf..5f88a4dfa 100644 --- a/eventcatalog/src/utils/collections/services.ts +++ b/eventcatalog/src/utils/collections/services.ts @@ -1,81 +1,95 @@ -import { getItemsFromCollectionByIdAndSemverOrLatest, getVersionForCollectionItem } from '@utils/collections/util'; import { getCollection } from 'astro:content'; import type { CollectionEntry } from 'astro:content'; import path from 'path'; import semver from 'semver'; -import type { CollectionTypes } from '@types'; +import type { CollectionMessageTypes, CollectionTypes } from '@types'; const PROJECT_DIR = process.env.PROJECT_DIR || process.cwd(); -import utils from '@eventcatalog/sdk'; +import utils, { type Domain } from '@eventcatalog/sdk'; +import { getDomains, getDomainsForService } from './domains'; +import { createVersionedMap, findInMap } from '@utils/collections/util'; export type Service = CollectionEntry<'services'>; +const CACHE_ENABLED = process.env.DISABLE_EVENTCATALOG_CACHE !== 'true'; interface Props { getAllVersions?: boolean; + returnBody?: boolean; } -// Cache for build time -let cachedServices: Record = { - allVersions: [], - currentVersions: [], -}; +// Simple in-memory cache +let memoryCache: Record = {}; -export const getServices = async ({ getAllVersions = true }: Props = {}): Promise => { - const cacheKey = getAllVersions ? 'allVersions' : 'currentVersions'; +export const getServices = async ({ getAllVersions = true, returnBody = false }: Props = {}): Promise => { + // console.time('✅ New getServices'); + const cacheKey = `${getAllVersions ? 'allVersions' : 'currentVersions'}-${returnBody ? 'withBody' : 'noBody'}`; - // Check if we have cached domains for this specific getAllVersions value - if (cachedServices[cacheKey].length > 0) { - return cachedServices[cacheKey]; + // Check if we have cached services + if (memoryCache[cacheKey] && memoryCache[cacheKey].length > 0 && CACHE_ENABLED) { + // console.timeEnd('✅ New getServices'); + return memoryCache[cacheKey]; } - // Get services that are not versioned - const services = await getCollection('services', (service) => { - return (getAllVersions || !service.filePath?.includes('versioned')) && service.data.hidden !== true; + // 1. Fetch all collections in parallel + const [allServices, allEvents, allCommands, allQueries, allEntities, allContainers, allFlows] = await Promise.all([ + getCollection('services'), + getCollection('events'), + getCollection('commands'), + getCollection('queries'), + getCollection('entities'), + getCollection('containers'), + getCollection('flows'), + ]); + + const allMessages = [...allEvents, ...allCommands, ...allQueries]; + + // 2. Build optimized maps + const serviceMap = createVersionedMap(allServices); + const messageMap = createVersionedMap(allMessages); + const entityMap = createVersionedMap(allEntities); + const containerMap = createVersionedMap(allContainers); + const flowMap = createVersionedMap(allFlows); + + // 3. Filter services + const targetServices = allServices.filter((service) => { + if (service.data.hidden === true) return false; + if (!getAllVersions && service.filePath?.includes('versioned')) return false; + return true; }); - const events = await getCollection('events'); - const commands = await getCollection('commands'); - const queries = await getCollection('queries'); - const entities = await getCollection('entities'); - const containers = await getCollection('containers'); - const allMessages = [...events, ...commands, ...queries]; + const { getResourceFolderName } = utils(process.env.PROJECT_DIR ?? ''); + + // 4. Enrich services using Map lookups (O(1)) + const processedServices = await Promise.all( + targetServices.map(async (service) => { + // Version info + const serviceVersions = serviceMap.get(service.data.id) || []; + const latestVersion = serviceVersions[0]?.data.version || service.data.version; + const versions = serviceVersions.map((s) => s.data.version); + + const sends = (service.data.sends || []) + .map((m) => findInMap(messageMap, m.id, m.version)) + .filter((e): e is CollectionEntry => !!e); + + const receives = (service.data.receives || []) + .map((m) => findInMap(messageMap, m.id, m.version)) + .filter((e): e is CollectionEntry => !!e); + + const mappedEntities = (service.data.entities || []) + .map((e) => findInMap(entityMap, e.id, e.version)) + .filter((e): e is CollectionEntry<'entities'> => !!e); + + const mappedWritesTo = (service.data.writesTo || []) + .map((c) => findInMap(containerMap, c.id, c.version)) + .filter((e): e is CollectionEntry<'containers'> => !!e); + + const mappedReadsFrom = (service.data.readsFrom || []) + .map((c) => findInMap(containerMap, c.id, c.version)) + .filter((e): e is CollectionEntry<'containers'> => !!e); + + const mappedFlows = (service.data.flows || []) + .map((f) => findInMap(flowMap, f.id, f.version)) + .filter((f): f is CollectionEntry<'flows'> => !!f); - // @ts-ignore // TODO: Fix this type - cachedServices[cacheKey] = await Promise.all( - services.map(async (service) => { - const { latestVersion, versions } = getVersionForCollectionItem(service, services); - - const sendsMessages = service.data.sends || []; - const receivesMessages = service.data.receives || []; - const serviceEntities = service.data.entities || []; - const serviceWritesTo = service.data.writesTo || []; - const serviceReadsFrom = service.data.readsFrom || []; - - const sends = sendsMessages - .map((message: any) => getItemsFromCollectionByIdAndSemverOrLatest(allMessages, message.id, message.version)) - .flat() - .filter((e: any) => e !== undefined); - - const receives = receivesMessages - .map((message: any) => getItemsFromCollectionByIdAndSemverOrLatest(allMessages, message.id, message.version)) - .flat() - .filter((e: any) => e !== undefined); - - const mappedEntities = serviceEntities - .map((entity: any) => getItemsFromCollectionByIdAndSemverOrLatest(entities, entity.id, entity.version)) - .flat() - .filter((e: any) => e !== undefined); - - const mappedWritesTo = serviceWritesTo - .map((container: any) => getItemsFromCollectionByIdAndSemverOrLatest(containers, container.id, container.version)) - .flat() - .filter((e: any) => e !== undefined); - - const mappedReadsFrom = serviceReadsFrom - .map((container: any) => getItemsFromCollectionByIdAndSemverOrLatest(containers, container.id, container.version)) - .flat() - .filter((e: any) => e !== undefined); - - const { getResourceFolderName } = utils(process.env.PROJECT_DIR ?? ''); const folderName = await getResourceFolderName( process.env.PROJECT_DIR ?? '', service.data.id, @@ -87,18 +101,19 @@ export const getServices = async ({ getAllVersions = true }: Props = {}): Promis ...service, data: { ...service.data, - writesTo: mappedWritesTo, - readsFrom: mappedReadsFrom, - receives, - sends, + writesTo: mappedWritesTo as any, + readsFrom: mappedReadsFrom as any, + flows: mappedFlows as any, + receives: receives as any, + sends: sends as any, versions, latestVersion, - entities: mappedEntities, + entities: mappedEntities as any, }, // TODO: verify if it could be deleted. nodes: { - receives, - sends, + receives: receives as any, + sends: sends as any, }, catalog: { // TODO: avoid use string replace at path due to win32 @@ -110,16 +125,20 @@ export const getServices = async ({ getAllVersions = true }: Props = {}): Promis publicPath: path.join('/generated', service.collection, serviceFolderName), type: 'service', }, + body: returnBody ? service.body : undefined, }; }) ); // order them by the name of the service - cachedServices[cacheKey].sort((a, b) => { + processedServices.sort((a, b) => { return (a.data.name || a.data.id).localeCompare(b.data.name || b.data.id); }); - return cachedServices[cacheKey]; + memoryCache[cacheKey] = processedServices; + // console.timeEnd('✅ New getServices'); + + return processedServices; }; export const getProducersOfMessage = (services: Service[], message: CollectionEntry<'events' | 'commands' | 'queries'>) => { @@ -209,3 +228,16 @@ export const getProducersAndConsumersForChannel = async (channel: CollectionEntr consumers: consumers ?? [], }; }; +export const getServicesNotInAnyDomain = async (): Promise => { + const services = await getServices({ getAllVersions: false }); + + // We need an async-aware filter: run all lookups, then filter by the results + const domainCountsForServices = await Promise.all( + services.map(async (service) => { + const domainsForService = await getDomainsForService(service); + return domainsForService.length; + }) + ); + + return services.filter((_, index) => domainCountsForServices[index] === 0); +}; diff --git a/eventcatalog/src/utils/collections/teams.ts b/eventcatalog/src/utils/collections/teams.ts new file mode 100644 index 000000000..257aea88e --- /dev/null +++ b/eventcatalog/src/utils/collections/teams.ts @@ -0,0 +1,94 @@ +import type { CollectionTypes } from '@types'; +import { getCollection } from 'astro:content'; +import type { CollectionEntry } from 'astro:content'; +import path from 'path'; + +export type Team = CollectionEntry<'teams'>; +const CACHE_ENABLED = process.env.DISABLE_EVENTCATALOG_CACHE !== 'true'; +// Cache for build time +let memoryCache: Team[] = []; + +export const getTeams = async (): Promise => { + // console.time('✅ New getTeams'); + if (memoryCache.length > 0 && CACHE_ENABLED) { + // console.timeEnd('✅ New getTeams'); + return memoryCache; + } + + // 1. Fetch all collections in parallel + const [allTeams, allDomains, allServices, allEvents, allCommands, allQueries] = await Promise.all([ + getCollection('teams'), + getCollection('domains'), + getCollection('services'), + getCollection('events'), + getCollection('commands'), + getCollection('queries'), + ]); + + // 2. Filter teams + const targetTeams = allTeams.filter((team) => team.data.hidden !== true); + + // 3. Build Owner Index: Map + // This index groups all items (domains, services, etc.) by their owner IDs. + // This allows O(1) lookup to find all items owned by a specific team. + const ownershipMap = new Map[]>(); + + const addToIndex = (items: CollectionEntry[]) => { + for (const item of items) { + if (item.data.owners) { + for (const owner of item.data.owners) { + if (!ownershipMap.has(owner.id)) { + ownershipMap.set(owner.id, []); + } + ownershipMap.get(owner.id)!.push(item); + } + } + } + }; + + addToIndex(allDomains); + addToIndex(allServices); + addToIndex(allEvents); + addToIndex(allCommands); + addToIndex(allQueries); + + // 4. Enrich teams using the ownership index + const processedTeams = targetTeams.map((team) => { + const teamId = team.data.id; + const ownedItems = ownershipMap.get(teamId) || []; + + // Categorize items + const ownedDomains = ownedItems.filter((i) => i.collection === 'domains') as CollectionEntry<'domains'>[]; + const ownedServices = ownedItems.filter((i) => i.collection === 'services') as CollectionEntry<'services'>[]; + const ownedEvents = ownedItems.filter((i) => i.collection === 'events') as CollectionEntry<'events'>[]; + const ownedCommands = ownedItems.filter((i) => i.collection === 'commands') as CollectionEntry<'commands'>[]; + const ownedQueries = ownedItems.filter((i) => i.collection === 'queries') as CollectionEntry<'queries'>[]; + + return { + ...team, + data: { + ...team.data, + ownedDomains, + ownedServices, + ownedCommands, + ownedQueries, + ownedEvents, + }, + catalog: { + path: path.join(team.collection, team.id.replace('/index.mdx', '')), + filePath: path.join(process.cwd(), 'src', 'catalog-files', team.collection, team.id.replace('/index.mdx', '')), + type: 'team', + }, + }; + }); + + // order them by the name of the team + processedTeams.sort((a, b) => { + return (a.data.name || a.data.id).localeCompare(b.data.name || b.data.id); + }); + + memoryCache = processedTeams; + // console.timeEnd('✅ New getTeams'); + + return processedTeams; +}; diff --git a/eventcatalog/src/utils/collections/users.ts b/eventcatalog/src/utils/collections/users.ts new file mode 100644 index 000000000..6e70d9294 --- /dev/null +++ b/eventcatalog/src/utils/collections/users.ts @@ -0,0 +1,122 @@ +import type { CollectionTypes } from '@types'; +import { getCollection } from 'astro:content'; +import type { CollectionEntry } from 'astro:content'; +import path from 'path'; + +export type User = CollectionEntry<'users'>; + +// Simple in-memory cache +let memoryCache: User[] = []; + +export const getUsers = async (): Promise => { + // console.time('✅ New getUsers'); + + if (memoryCache.length > 0) { + // console.timeEnd('✅ New getUsers'); + return memoryCache; + } + + // 1. Fetch all collections in parallel + const [allUsers, allDomains, allServices, allEvents, allCommands, allQueries, allTeams] = await Promise.all([ + getCollection('users'), + getCollection('domains'), + getCollection('services'), + getCollection('events'), + getCollection('commands'), + getCollection('queries'), + getCollection('teams'), + ]); + + // 2. Filter users + const targetUsers = allUsers.filter((user) => user.data.hidden !== true); + const visibleTeams = allTeams.filter((team) => team.data.hidden !== true); + + // 3. Process users (Optimization: Iterate once over relationships if possible, + // but since we need to check ownership for EACH user against ALL items, + // we can't easily invert the map without building an "owner" index first. + // Given users/teams count is usually lower than events/services, iterating users and filtering items is acceptable, + // OR we can index items by ownerID for O(1) lookup. Let's try indexing items by ownerID.) + + // Build Owner Index: Map + const ownershipMap = new Map[]>(); + + const addToIndex = (items: CollectionEntry[]) => { + for (const item of items) { + if (item.data.owners) { + for (const owner of item.data.owners) { + if (!ownershipMap.has(owner.id)) { + ownershipMap.set(owner.id, []); + } + ownershipMap.get(owner.id)!.push(item); + } + } + } + }; + + addToIndex(allDomains); + addToIndex(allServices); + addToIndex(allEvents); + addToIndex(allCommands); + addToIndex(allQueries); + + // Team Membership Index: Map + const teamMembershipMap = new Map(); + for (const team of visibleTeams) { + if (team.data.members) { + for (const member of team.data.members) { + if (!teamMembershipMap.has(member.id)) { + teamMembershipMap.set(member.id, []); + } + teamMembershipMap.get(member.id)!.push(team); + } + } + } + + const mappedUsers = targetUsers.map((user) => { + const userId = user.data.id; + const associatedTeams = teamMembershipMap.get(userId) || []; + const associatedTeamIds = associatedTeams.map((t) => t.data.id); + + // Collect all owned items directly owned by user OR by their teams + const directOwnedItems = ownershipMap.get(userId) || []; + const teamOwnedItems = associatedTeamIds.flatMap((teamId) => ownershipMap.get(teamId) || []); + + // Combine and deduplicate items (by ID+Version or just reference equality since they come from same source arrays) + const allOwnedItems = Array.from(new Set([...directOwnedItems, ...teamOwnedItems])); + + // Categorize items + const ownedDomains = allOwnedItems.filter((i) => i.collection === 'domains') as CollectionEntry<'domains'>[]; + const ownedServices = allOwnedItems.filter((i) => i.collection === 'services') as CollectionEntry<'services'>[]; + const ownedEvents = allOwnedItems.filter((i) => i.collection === 'events') as CollectionEntry<'events'>[]; + const ownedCommands = allOwnedItems.filter((i) => i.collection === 'commands') as CollectionEntry<'commands'>[]; + const ownedQueries = allOwnedItems.filter((i) => i.collection === 'queries') as CollectionEntry<'queries'>[]; + + return { + ...user, + data: { + ...user.data, + ownedDomains, + ownedServices, + ownedEvents, + ownedCommands, + ownedQueries, + associatedTeams, + }, + catalog: { + path: path.join(user.collection, user.id.replace('/index.mdx', '')), + filePath: path.join(process.cwd(), 'src', 'catalog-files', user.collection, user.id.replace('/index.mdx', '')), + type: 'user', + }, + }; + }); + + // order them by the name of the user + mappedUsers.sort((a, b) => { + return (a.data.name || a.data.id).localeCompare(b.data.name || b.data.id); + }); + + memoryCache = mappedUsers; + // console.timeEnd('✅ New getUsers'); + + return mappedUsers; +}; diff --git a/eventcatalog/src/utils/collections/util.ts b/eventcatalog/src/utils/collections/util.ts index d8699af3e..6194a32c8 100644 --- a/eventcatalog/src/utils/collections/util.ts +++ b/eventcatalog/src/utils/collections/util.ts @@ -1,6 +1,6 @@ import type { CollectionTypes } from '@types'; import type { CollectionEntry } from 'astro:content'; -import { coerce, compare, eq, satisfies as satisfiesRange } from 'semver'; +import semver, { coerce, compare, eq, satisfies as satisfiesRange } from 'semver'; export const getPreviousVersion = (version: string, versions: string[]) => { const index = versions.indexOf(version); @@ -116,6 +116,7 @@ export const resourceToCollectionMap = { user: 'users', team: 'teams', container: 'containers', + entity: 'entities', } as const; export const collectionToResourceMap = { @@ -129,6 +130,7 @@ export const collectionToResourceMap = { users: 'user', teams: 'team', containers: 'container', + entities: 'entity', } as const; export const getDeprecatedDetails = (item: CollectionEntry) => { @@ -165,3 +167,57 @@ export const removeContentFromCollection = (collection: CollectionEntry(items: T[]) => { + const map = new Map(); + + for (const item of items) { + const id = item.data.id; + if (!map.has(id)) map.set(id, []); + map.get(id)!.push(item); + } + + // Sort every entry so index [0] is always the latest version + for (const [key, list] of map.entries()) { + list.sort((a, b) => { + // specific version sorting logic (fallback to string compare if not valid semver) + const vA = a.data.version || '0.0.0'; + const vB = b.data.version || '0.0.0'; + return semver.valid(vB) && semver.valid(vA) ? semver.rcompare(vA, vB) : vB.localeCompare(vA); + }); + } + return map; +}; + +/** + * Fast lookup helper. + * If version is provided, find it. If not, return the first (latest) item. + */ +export const findInMap = ( + map: Map, + id: string, + version?: string +): T | undefined => { + const items = map.get(id); + if (!items || items.length === 0) return undefined; + + // If no version specified or 'latest', return the first item (which is sorted to be latest) + if (!version || version === 'latest') return items[0]; + + // Try exact match + const exactMatch = items.find((i) => i.data.version === version); + if (exactMatch) return exactMatch; + + // Try semver match if not exact + if (semver.validRange(version)) { + return items.find((i) => semver.satisfies(i.data.version || '0.0.0', version)); + } + + return undefined; +}; diff --git a/eventcatalog/src/utils/commands.ts b/eventcatalog/src/utils/commands.ts deleted file mode 100644 index 1ce0c2344..000000000 --- a/eventcatalog/src/utils/commands.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { getCollection } from 'astro:content'; -import type { CollectionEntry } from 'astro:content'; -import path from 'path'; -import { getVersionForCollectionItem, satisfies } from './collections/util'; -import utils from '@eventcatalog/sdk'; - -const PROJECT_DIR = process.env.PROJECT_DIR || process.cwd(); - -type Command = CollectionEntry<'commands'> & { - catalog: { - path: string; - filePath: string; - type: string; - }; -}; - -interface Props { - getAllVersions?: boolean; - hydrateServices?: boolean; -} - -// cache for build time -let cachedCommands: Record = { - allVersions: [], - currentVersions: [], -}; - -export const getCommands = async ({ getAllVersions = true, hydrateServices = true }: Props = {}): Promise => { - const cacheKey = getAllVersions ? 'allVersions' : 'currentVersions'; - - if (cachedCommands[cacheKey].length > 0 && hydrateServices) { - return cachedCommands[cacheKey]; - } - - const commands = await getCollection('commands', (command) => { - return (getAllVersions || !command.filePath?.includes('versioned')) && command.data.hidden !== true; - }); - - const services = await getCollection('services'); - const allChannels = await getCollection('channels'); - - // @ts-ignore - cachedCommands[cacheKey] = await Promise.all( - commands.map(async (command) => { - const { latestVersion, versions } = getVersionForCollectionItem(command, commands); - - const producers = services - .filter((service) => { - return service.data.sends?.some((item) => { - if (item.id != command.data.id) return false; - if (item.version == 'latest' || item.version == undefined) return command.data.version == latestVersion; - return satisfies(command.data.version, item.version); - }); - }) - .map((service) => { - if (!hydrateServices) return { id: service.data.id, version: service.data.version }; - return service; - }); - - const consumers = services - .filter((service) => { - return service.data.receives?.some((item) => { - if (item.id != command.data.id) return false; - if (item.version == 'latest' || item.version == undefined) return command.data.version == latestVersion; - return satisfies(command.data.version, item.version); - }); - }) - .map((service) => { - if (!hydrateServices) return { id: service.data.id, version: service.data.version }; - return service; - }); - - const messageChannels = command.data.channels || []; - const channelsForCommand = allChannels.filter((c) => messageChannels.some((channel) => c.data.id === channel.id)); - - const { getResourceFolderName } = utils(process.env.PROJECT_DIR ?? ''); - const folderName = await getResourceFolderName( - process.env.PROJECT_DIR ?? '', - command.data.id, - command.data.version.toString() - ); - const commandFolderName = folderName ?? command.id.replace(`-${command.data.version}`, ''); - - return { - ...command, - data: { - ...command.data, - messageChannels: channelsForCommand, - producers, - consumers, - versions, - latestVersion, - }, - catalog: { - path: path.join(command.collection, command.id.replace('/index.mdx', '')), - absoluteFilePath: path.join(PROJECT_DIR, command.collection, command.id.replace('/index.mdx', '/index.md')), - astroContentFilePath: path.join(process.cwd(), 'src', 'content', command.collection, command.id), - filePath: path.join(process.cwd(), 'src', 'catalog-files', command.collection, command.id.replace('/index.mdx', '')), - publicPath: path.join('/generated', command.collection, commandFolderName), - type: 'command', - }, - }; - }) - ); - - // order them by the name of the command - cachedCommands[cacheKey].sort((a, b) => { - return (a.data.name || a.data.id).localeCompare(b.data.name || b.data.id); - }); - - return cachedCommands[cacheKey]; -}; diff --git a/eventcatalog/src/utils/events.ts b/eventcatalog/src/utils/events.ts deleted file mode 100644 index 5f8951ea1..000000000 --- a/eventcatalog/src/utils/events.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { getCollection } from 'astro:content'; -import type { CollectionEntry } from 'astro:content'; -import path from 'path'; -import { getVersionForCollectionItem, satisfies } from './collections/util'; -import utils from '@eventcatalog/sdk'; - -const PROJECT_DIR = process.env.PROJECT_DIR || process.cwd(); - -type Event = CollectionEntry<'events'> & { - catalog: { - path: string; - filePath: string; - type: string; - }; -}; - -interface Props { - getAllVersions?: boolean; - hydrateServices?: boolean; -} - -// cache for build time -let cachedEvents: Record = { - allVersions: [], - currentVersions: [], -}; - -export const getEvents = async ({ getAllVersions = true, hydrateServices = true }: Props = {}): Promise => { - const cacheKey = getAllVersions ? 'allVersions' : 'currentVersions'; - - if (cachedEvents[cacheKey].length > 0 && hydrateServices) { - return cachedEvents[cacheKey]; - } - - const events = await getCollection('events', (event) => { - return (getAllVersions || !event.filePath?.includes('versioned')) && event.data.hidden !== true; - }); - - const services = await getCollection('services'); - const allChannels = await getCollection('channels'); - - // @ts-ignore - cachedEvents[cacheKey] = await Promise.all( - events.map(async (event) => { - const { latestVersion, versions } = getVersionForCollectionItem(event, events); - - const producers = services - .filter((service) => - service.data.sends?.some((item) => { - if (item.id != event.data.id) return false; - if (item.version == 'latest' || item.version == undefined) return event.data.version == latestVersion; - return satisfies(event.data.version, item.version); - }) - ) - .map((service) => { - if (!hydrateServices) return { id: service.data.id, version: service.data.version }; - return service; - }); - - const consumers = services - .filter((service) => - service.data.receives?.some((item) => { - if (item.id != event.data.id) return false; - if (item.version == 'latest' || item.version == undefined) return event.data.version == latestVersion; - return satisfies(event.data.version, item.version); - }) - ) - .map((service) => { - if (!hydrateServices) return { id: service.data.id, version: service.data.version }; - return service; - }); - - const messageChannels = event.data.channels || []; - const channelsForEvent = allChannels.filter((c) => messageChannels.some((channel) => c.data.id === channel.id)); - - const { getResourceFolderName } = utils(process.env.PROJECT_DIR ?? ''); - const folderName = await getResourceFolderName(process.env.PROJECT_DIR ?? '', event.data.id, event.data.version.toString()); - const eventFolderName = folderName ?? event.id.replace(`-${event.data.version}`, ''); - - return { - ...event, - data: { - ...event.data, - messageChannels: channelsForEvent, - producers, - consumers, - versions, - latestVersion, - }, - catalog: { - path: path.join(event.collection, event.id.replace('/index.mdx', '')), - absoluteFilePath: path.join(PROJECT_DIR, event.collection, event.id.replace('/index.mdx', '/index.md')), - astroContentFilePath: path.join(process.cwd(), 'src', 'content', event.collection, event.id), - filePath: path.join(process.cwd(), 'src', 'catalog-files', event.collection, event.id.replace('/index.mdx', '')), - publicPath: path.join('/generated', event.collection, eventFolderName), - type: 'event', - }, - }; - }) - ); - - // order them by the name of the event - cachedEvents[cacheKey].sort((a, b) => { - return (a.data.name || a.data.id).localeCompare(b.data.name || b.data.id); - }); - - return cachedEvents[cacheKey]; -}; diff --git a/eventcatalog/src/utils/feature.ts b/eventcatalog/src/utils/feature.ts index 45a0e605f..fa02e9550 100644 --- a/eventcatalog/src/utils/feature.ts +++ b/eventcatalog/src/utils/feature.ts @@ -34,7 +34,7 @@ export const showCustomBranding = () => { return isEventCatalogStarterEnabled() || isEventCatalogScaleEnabled(); }; -export const isChangelogEnabled = () => config?.changelog?.enabled ?? true; +export const isChangelogEnabled = () => config?.changelog?.enabled ?? false; export const isCustomDocsEnabled = () => isEventCatalogStarterEnabled() || isEventCatalogScaleEnabled(); export const isEventCatalogChatEnabled = () => { @@ -55,5 +55,5 @@ export const isAuthEnabled = () => { }; export const isSSR = () => config?.output === 'server'; - +export const isRSSEnabled = () => config?.rss?.enabled ?? false; export const isVisualiserEnabled = () => config?.visualiser?.enabled ?? true; diff --git a/eventcatalog/src/utils/collections/file-diffs.ts b/eventcatalog/src/utils/file-diffs.ts similarity index 98% rename from eventcatalog/src/utils/collections/file-diffs.ts rename to eventcatalog/src/utils/file-diffs.ts index 4a2345cbd..d1131b86a 100644 --- a/eventcatalog/src/utils/collections/file-diffs.ts +++ b/eventcatalog/src/utils/file-diffs.ts @@ -2,7 +2,7 @@ import { readdir, readFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { formatPatch, structuredPatch } from 'diff'; import { html, parse } from 'diff2html'; -import { getItemsFromCollectionByIdAndSemverOrLatest } from './util'; +import { getItemsFromCollectionByIdAndSemverOrLatest } from './collections/util'; import type { CollectionEntry } from 'astro:content'; import type { CollectionTypes } from '@types'; diff --git a/eventcatalog/src/utils/generators/index.ts b/eventcatalog/src/utils/generators/index.ts deleted file mode 100644 index 918e129b4..000000000 --- a/eventcatalog/src/utils/generators/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Should really only be used on the server side -// If users are using path or fs in the eventcatalog.config.js file, it will break the build (for now) - -import config from '@config'; - -export const getConfigurationForGivenGenerator = (generator: string) => { - const generators = config.generators ?? []; - const generatorConfig = generators.find((g: any) => g[0] === generator); - return generatorConfig?.[1]; -}; diff --git a/eventcatalog/src/utils/node-graphs/container-node-graph.ts b/eventcatalog/src/utils/node-graphs/container-node-graph.ts index a7ccc9b14..56c96923f 100644 --- a/eventcatalog/src/utils/node-graphs/container-node-graph.ts +++ b/eventcatalog/src/utils/node-graphs/container-node-graph.ts @@ -17,12 +17,14 @@ interface Props { } export const getNodesAndEdges = async ({ id, version, defaultFlow, mode = 'simple', channelRenderMode = 'flat' }: Props) => { + // 1. Fetch data const containers = await getContainers(); const flow = defaultFlow || createDagreGraph({ ranksep: 300, nodesep: 50 }); const nodes = [] as any, edges = [] as any; + // Optimized: Use find since we're looking for a specific item const container = containers.find((container) => container.data.id === id && container.data.version === version); // Nothing found... diff --git a/eventcatalog/src/utils/node-graphs/domain-entity-map.ts b/eventcatalog/src/utils/node-graphs/domain-entity-map.ts index 394f42632..24e856a24 100644 --- a/eventcatalog/src/utils/node-graphs/domain-entity-map.ts +++ b/eventcatalog/src/utils/node-graphs/domain-entity-map.ts @@ -2,9 +2,9 @@ import { getCollection, getEntry } from 'astro:content'; import { generateIdForNode } from './utils/utils'; import ELK from 'elkjs/lib/elk.bundled.js'; import { MarkerType } from '@xyflow/react'; -import { getItemsFromCollectionByIdAndSemverOrLatest } from '@utils/collections/util'; +import { createVersionedMap, findInMap } from '@utils/collections/util'; import { getVersionFromCollection } from '@utils/collections/versions'; -import { getEntities, type Entity } from '@utils/entities'; +import { getEntities, type Entity } from '@utils/collections/entities'; import { getDomains, type Domain } from '@utils/collections/domains'; import { getServices, type Service } from '@utils/collections/services'; @@ -21,9 +21,8 @@ export const getNodesAndEdges = async ({ id, version, entities, type = 'domains' let nodes = [] as any, edges = [] as any; - const allDomains = await getDomains(); - const allEntities = await getEntities(); - const allServices = await getServices(); + // 1. Fetch all collections in parallel + const [allDomains, allEntities, allServices] = await Promise.all([getDomains(), getEntities(), getServices()]); let resource = null; @@ -65,14 +64,25 @@ export const getNodesAndEdges = async ({ id, version, entities, type = 'domains' const externalToDomain = Array.from(new Set(listOfReferencedEntities as string[])) // Remove duplicates .filter((entityId: any) => !resourceEntities.some((domainEntity: any) => domainEntity.id === entityId)); + // 2. Build optimized maps + // Only build domain map if we have domains to search + // Only build entity map if we have entities to search + const entityMap = createVersionedMap(allEntities); + // Helper function to find which domain an entity belongs to + // Optimized to use direct iteration over domains (domains usually contain entity arrays) + // We can't easily map entity->domain without scanning domains first unless we build a reverse index. + // Given domains count is usually manageable, scanning is acceptable, OR we could build an index if needed. + // For now, let's keep the scan but make it efficient. const findEntityDomain = (entityId: string) => { return allDomains.find((domain) => domain.data.entities?.some((domainEntity: any) => domainEntity.data.id === entityId)); }; const addedExternalEntities = []; + for (const entityId of externalToDomain) { - const externalEntity = getItemsFromCollectionByIdAndSemverOrLatest(allEntities, entityId as string, 'latest')[0] as Entity; + // 3. Use map lookup + const externalEntity = findInMap(entityMap, entityId as string, 'latest') as Entity; if (externalEntity) { const nodeId = generateIdForNode(externalEntity); diff --git a/eventcatalog/src/utils/node-graphs/domains-canvas.ts b/eventcatalog/src/utils/node-graphs/domains-canvas.ts index 802ca7bba..a3dc065ea 100644 --- a/eventcatalog/src/utils/node-graphs/domains-canvas.ts +++ b/eventcatalog/src/utils/node-graphs/domains-canvas.ts @@ -1,9 +1,10 @@ import { getCollection, type CollectionEntry } from 'astro:content'; import dagre from 'dagre'; import { generateIdForNode, createDagreGraph, calculatedNodes, createEdge } from '@utils/node-graphs/utils/utils'; -import { getItemsFromCollectionByIdAndSemverOrLatest } from '@utils/collections/util'; +import { findInMap, createVersionedMap } from '@utils/collections/util'; import type { Node, Edge } from '@xyflow/react'; import { getDomains } from '@utils/collections/domains'; +import type { CollectionMessageTypes } from '@types'; interface DomainCanvasData { domainNodes: Node[]; @@ -69,10 +70,14 @@ export const getDomainsCanvasData = async (): Promise => { } as Node); } - // Get all messages for version resolution - const allMessages = await getCollection('events') - .then((events) => Promise.all([events, getCollection('commands'), getCollection('queries')])) - .then(([events, commands, queries]) => [...events, ...commands, ...queries]); + // Get all messages for version resolution in parallel + const [events, commands, queries] = await Promise.all([ + getCollection('events'), + getCollection('commands'), + getCollection('queries'), + ]); + const allMessages = [...events, ...commands, ...queries]; + const messageMap = createVersionedMap(allMessages); // Map to track unique messages and their publishers/consumers across domains const messageRelationships = new Map< @@ -89,9 +94,9 @@ export const getDomainsCanvasData = async (): Promise => { domainData.services.forEach((service: any) => { // Track messages this service sends const sendsRaw = service.data.sends ?? []; + const sendsHydrated = sendsRaw - .map((message: any) => getItemsFromCollectionByIdAndSemverOrLatest(allMessages, message.id, message.version)) - .flat() + .map((message: any) => findInMap(messageMap, message.id, message.version)) .filter((e: any) => e !== undefined); sendsHydrated.forEach((sentMessage: any) => { @@ -115,8 +120,7 @@ export const getDomainsCanvasData = async (): Promise => { // Track messages this service receives const receivesRaw = service.data.receives ?? []; const receivesHydrated = receivesRaw - .map((message: any) => getItemsFromCollectionByIdAndSemverOrLatest(allMessages, message.id, message.version)) - .flat() + .map((message: any) => findInMap(messageMap, message.id, message.version)) .filter((e: any) => e !== undefined); receivesHydrated.forEach((receivedMessage: any) => { @@ -154,7 +158,7 @@ export const getDomainsCanvasData = async (): Promise => { if (crossesDomainBoundary) { // Find the actual message object - const messageObject = allMessages.find((m) => m.data.id === message.id && m.data.version === message.version); + const messageObject = findInMap(messageMap, message.id, message.version) as CollectionEntry; if (messageObject) { const messageNodeId = `message-${messageKey}`; diff --git a/eventcatalog/src/utils/node-graphs/domains-node-graph.ts b/eventcatalog/src/utils/node-graphs/domains-node-graph.ts index 469f3ebe9..6a77d8adb 100644 --- a/eventcatalog/src/utils/node-graphs/domains-node-graph.ts +++ b/eventcatalog/src/utils/node-graphs/domains-node-graph.ts @@ -9,7 +9,7 @@ import { } from '@utils/node-graphs/utils/utils'; import { getNodesAndEdges as getServicesNodeAndEdges } from './services-node-graph'; import merge from 'lodash.merge'; -import { getItemsFromCollectionByIdAndSemverOrLatest } from '@utils/collections/util'; +import { createVersionedMap, findInMap } from '@utils/collections/util'; import type { Node } from '@xyflow/react'; import { getProducersOfMessage } from '@utils/collections/services'; @@ -25,22 +25,29 @@ export const getNodesAndEdgesForDomainContextMap = async ({ defaultFlow = null } let nodes = [] as any, edges = [] as any; - const allDomains = await getCollection('domains'); - const domains = allDomains.filter((domain) => !domain.id.includes('/versioned')); - const services = await getCollection('services'); - - const events = await getCollection('events'); - const commands = await getCollection('commands'); - const queries = await getCollection('queries'); + // 1. Parallel Fetching + const [allDomains, services, events, commands, queries] = await Promise.all([ + getCollection('domains'), + getCollection('services'), + getCollection('events'), + getCollection('commands'), + getCollection('queries'), + ]); + const domains = allDomains.filter((domain) => !domain.id.includes('/versioned')); const messages = [...events, ...commands, ...queries]; + // 2. Build optimized maps + const serviceMap = createVersionedMap(services); + const messageMap = createVersionedMap(messages); + domains.forEach((domain, index) => { const nodeId = generateIdForNode(domain); const rawServices = domain.data.services ?? []; + + // Optimized service resolution const domainServices = rawServices - .map((service) => getItemsFromCollectionByIdAndSemverOrLatest(services, service.id, service.version)) - .flat() + .map((service) => findInMap(serviceMap, service.id, service.version)) .filter((e) => e !== undefined); // Calculate domain node size based on services @@ -60,14 +67,12 @@ export const getNodesAndEdgesForDomainContextMap = async ({ defaultFlow = null } const rowIndex = Math.floor(index / DOMAINS_PER_ROW); const colIndex = index % DOMAINS_PER_ROW; - const test = servicesCount * SERVICE_HEIGHT + PADDING * 2; - nodes.push({ id: nodeId, type: 'group', position: { - x: colIndex * (domainWidth + 400), // Increased from 100 to 400px gap between domains - y: rowIndex * (domainHeight + 300), // Increased from 100 to 300px gap between rows + x: colIndex * (domainWidth + 400), + y: rowIndex * (domainHeight + 300), }, style: { width: domainWidth, @@ -107,7 +112,6 @@ export const getNodesAndEdgesForDomainContextMap = async ({ defaultFlow = null } domainServices.forEach((service, serviceIndex) => { const row = Math.floor(serviceIndex / SERVICES_PER_ROW); const col = serviceIndex % SERVICES_PER_ROW; - const serviceNodeId = `service-${domain.id}-${service.id}`; // Add spacing between services const SERVICE_MARGIN = 25; @@ -136,10 +140,13 @@ export const getNodesAndEdgesForDomainContextMap = async ({ defaultFlow = null } const rawReceives = service.data.receives ?? []; const rawSends = service.data.sends ?? []; + // Optimized message resolution const receives = rawReceives - .map((receive) => getItemsFromCollectionByIdAndSemverOrLatest(messages, receive.id, receive.version)) - .flat(); - const sends = rawSends.map((send) => getItemsFromCollectionByIdAndSemverOrLatest(messages, send.id, send.version)).flat(); + .map((receive) => findInMap(messageMap, receive.id, receive.version)) + .filter((msg): msg is any => !!msg); // Filter undefined + + // Note: 'sends' was defined but not used in the original loop logic for edges? + // Based on original code, it iterates `receives`. for (const receive of receives) { const producers = getProducersOfMessage(services, receive); @@ -148,19 +155,6 @@ export const getNodesAndEdgesForDomainContextMap = async ({ defaultFlow = null } const isSameDomain = domainServices.some((domainService) => domainService.data.id === producer.data.id); if (!isSameDomain) { - // WIP... adding messages? - // edges.push(createEdge({ - // id: generatedIdForEdge(receive, service), - // source: generateIdForNode(receive), - // target: generateIdForNode(service), - // label: getEdgeLabelForServiceAsTarget(receive), - // zIndex: 1000, - // })); - - // Find the producer and consumer nodes to get their positions - // const producerNode = nodes.find(n => n.id === generateIdForNode(producer)); - // const consumerNode = nodes.find(n => n.id === generateIdForNode(service)); - edges.push( createEdge({ id: generatedIdForEdge(producer, service), @@ -170,32 +164,6 @@ export const getNodesAndEdgesForDomainContextMap = async ({ defaultFlow = null } zIndex: 1000, }) ); - - // // Calculate middle position between producer and consumer - // const messageX = (producerNode?.position?.x ?? 0) + - // ((consumerNode?.position?.x ?? 0) - (producerNode?.position?.x ?? 0)) / 2; - // const messageY = (producerNode?.position?.y ?? 0) + - // ((consumerNode?.position?.y ?? 0) - (producerNode?.position?.y ?? 0)) / 2; - - // nodes.push({ - // id: generateIdForNode(receive), - // type: receive.collection, - // sourcePosition: 'right', - // targetPosition: 'left', - // data: { - // message: receive, - // mode: 'full', - // }, - // position: { x: messageX, y: messageY }, - // }); - - // edges.push(createEdge({ - // id: generatedIdForEdge(producer, receive), - // source: generateIdForNode(producer), - // target: generateIdForNode(receive), - // label: getEdgeLabelForServiceAsTarget(receive), - // zIndex: 1000, - // })); } } } @@ -230,7 +198,8 @@ export const getNodesAndEdges = async ({ let nodes = new Map(), edges = new Map(); - const domains = await getCollection('domains'); + // 1. Parallel Fetching + const [domains, services] = await Promise.all([getCollection('domains'), getCollection('services')]); const domain = domains.find((service) => service.data.id === id && service.data.version === version); @@ -242,19 +211,22 @@ export const getNodesAndEdges = async ({ }; } + // 2. Build optimized maps + const serviceMap = createVersionedMap(services); + const domainMap = createVersionedMap(domains); + const rawServices = domain?.data.services || []; const rawSubDomains = domain?.data.domains || []; - const servicesCollection = await getCollection('services'); - + // Optimized hydration const domainServicesWithVersion = rawServices - .map((service) => getItemsFromCollectionByIdAndSemverOrLatest(servicesCollection, service.id, service.version)) - .flat() + .map((service) => findInMap(serviceMap, service.id, service.version)) + .filter((s): s is any => !!s) .map((svc) => ({ id: svc.data.id, version: svc.data.version })); const domainSubDomainsWithVersion = rawSubDomains - .map((subDomain) => getItemsFromCollectionByIdAndSemverOrLatest(domains, subDomain.id, subDomain.version)) - .flat() + .map((subDomain) => findInMap(domainMap, subDomain.id, subDomain.version)) + .filter((d): d is any => !!d) .map((svc) => ({ id: svc.data.id, version: svc.data.version })); // Get all the nodes for everyhing diff --git a/eventcatalog/src/utils/node-graphs/flows-node-graph.ts b/eventcatalog/src/utils/node-graphs/flows-node-graph.ts index 3a5a39513..576e98a63 100644 --- a/eventcatalog/src/utils/node-graphs/flows-node-graph.ts +++ b/eventcatalog/src/utils/node-graphs/flows-node-graph.ts @@ -3,7 +3,7 @@ import dagre from 'dagre'; import { createDagreGraph, calculatedNodes } from '@utils/node-graphs/utils/utils'; import { MarkerType } from '@xyflow/react'; import type { Node as NodeType } from '@xyflow/react'; -import { getItemsFromCollectionByIdAndSemverOrLatest } from '@utils/collections/util'; +import { createVersionedMap, findInMap } from '@utils/collections/util'; type DagreGraph = any; @@ -15,9 +15,8 @@ interface Props { renderAllEdges?: boolean; } -const getServiceNode = (step: any, services: CollectionEntry<'services'>[]) => { - const servicesForVersion = getItemsFromCollectionByIdAndSemverOrLatest(services, step.service.id, step.service.version); - const service = servicesForVersion?.[0]; +const getServiceNode = (step: any, serviceMap: Map) => { + const service = findInMap(serviceMap, step.service.id, step.service.version); return { ...step, type: service ? service.collection : 'step', @@ -25,9 +24,8 @@ const getServiceNode = (step: any, services: CollectionEntry<'services'>[]) => { }; }; -const getFlowNode = (step: any, flows: CollectionEntry<'flows'>[]) => { - const flowsForVersion = getItemsFromCollectionByIdAndSemverOrLatest(flows, step.flow.id, step.flow.version); - const flow = flowsForVersion?.[0]; +const getFlowNode = (step: any, flowMap: Map) => { + const flow = findInMap(flowMap, step.flow.id, step.flow.version); return { ...step, type: flow ? flow.collection : 'step', @@ -35,9 +33,8 @@ const getFlowNode = (step: any, flows: CollectionEntry<'flows'>[]) => { }; }; -const getMessageNode = (step: any, messages: CollectionEntry<'events' | 'commands' | 'queries'>[]) => { - const messagesForVersion = getItemsFromCollectionByIdAndSemverOrLatest(messages, step.message.id, step.message.version); - const message = messagesForVersion[0]; +const getMessageNode = (step: any, messageMap: Map) => { + const message = findInMap(messageMap, step.message.id, step.message.version); return { ...step, type: message ? message.collection : 'step', @@ -50,7 +47,15 @@ export const getNodesAndEdges = async ({ id, defaultFlow, version, mode = 'simpl const nodes = [] as any, edges = [] as any; - const flows = await getCollection('flows'); + // Fetch all collections in parallel + const [flows, events, commands, queries, services] = await Promise.all([ + getCollection('flows'), + getCollection('events'), + getCollection('commands'), + getCollection('queries'), + getCollection('services'), + ]); + const flow = flows.find((flow) => flow.data.id === id && flow.data.version === version); // Nothing found... @@ -61,20 +66,19 @@ export const getNodesAndEdges = async ({ id, defaultFlow, version, mode = 'simpl }; } - const events = await getCollection('events'); - const commands = await getCollection('commands'); - const queries = await getCollection('queries'); - const services = await getCollection('services'); - + // Build maps for O(1) lookups const messages = [...events, ...commands, ...queries]; + const messageMap = createVersionedMap(messages); + const serviceMap = createVersionedMap(services); + const flowMap = createVersionedMap(flows); const steps = flow?.data.steps || []; // Hydrate the steps with information they may need. const hydratedSteps = steps.map((step: any) => { - if (step.service) return getServiceNode(step, services); - if (step.flow) return getFlowNode(step, flows); - if (step.message) return getMessageNode(step, messages); + if (step.service) return getServiceNode(step, serviceMap); + if (step.flow) return getFlowNode(step, flowMap); + if (step.message) return getMessageNode(step, messageMap); if (step.actor) return { ...step, type: 'actor', actor: step.actor }; if (step.custom) return { ...step, type: 'custom', custom: step.custom }; if (step.externalSystem) return { ...step, type: 'externalSystem', externalSystem: step.externalSystem }; diff --git a/eventcatalog/src/utils/node-graphs/message-node-graph.ts b/eventcatalog/src/utils/node-graphs/message-node-graph.ts index cff002722..984932991 100644 --- a/eventcatalog/src/utils/node-graphs/message-node-graph.ts +++ b/eventcatalog/src/utils/node-graphs/message-node-graph.ts @@ -1,5 +1,5 @@ // import { getColor } from '@utils/colors'; -import { getEvents } from '@utils/events'; +import { getEvents } from '@utils/collections/events'; import type { CollectionEntry } from 'astro:content'; import dagre from 'dagre'; import { @@ -17,15 +17,17 @@ import { findMatchingNodes, getItemsFromCollectionByIdAndSemverOrLatest, getLatestVersionInCollectionById, + createVersionedMap, + findInMap, } from '@utils/collections/util'; import type { CollectionMessageTypes } from '@types'; -import { getCommands } from '@utils/commands'; -import { getQueries } from '@utils/queries'; +import { getCommands } from '@utils/collections/commands'; +import { getQueries } from '@utils/collections/queries'; import { createNode } from './utils/utils'; import { getConsumersOfMessage, getProducersOfMessage } from '@utils/collections/services'; import { getNodesAndEdgesForChannelChain } from './channel-node-graph'; -import { getChannelChain, isChannelsConnected } from '@utils/channels'; -import { getChannels } from '@utils/channels'; +import { getChannelChain, isChannelsConnected } from '@utils/collections/channels'; +import { getChannels } from '@utils/collections/channels'; type DagreGraph = any; @@ -64,6 +66,9 @@ const getNodesAndEdges = async ({ }; } + // Pre-calculate channel map for O(1) lookups + const channelMap = createVersionedMap(channels); + // We always render the message itself nodes.push({ id: generateIdForNode(message), @@ -127,11 +132,7 @@ const getNodesAndEdges = async ({ // If the producer has channels defined, we need to render them for (const producerChannel of producerChannelConfiguration) { - const channel = getItemsFromCollectionByIdAndSemverOrLatest( - channels, - producerChannel.id, - producerChannel.version - )[0] as CollectionEntry<'channels'>; + const channel = findInMap(channelMap, producerChannel.id, producerChannel.version) as CollectionEntry<'channels'>; // If we cannot find the channel in EventCatalog, we just connect the producer to the event directly if (!channel) { @@ -219,11 +220,7 @@ const getNodesAndEdges = async ({ // If the consumer has channels defined, we try and render them for (const consumerChannel of consumerChannelConfiguration) { - const channel = getItemsFromCollectionByIdAndSemverOrLatest( - channels, - consumerChannel.id, - consumerChannel.version - )[0] as CollectionEntry<'channels'>; + const channel = findInMap(channelMap, consumerChannel.id, consumerChannel.version) as CollectionEntry<'channels'>; // If we cannot find the channel in EventCatalog, we connect the message directly to the consumer if (!channel) { @@ -246,18 +243,18 @@ const getNodesAndEdges = async ({ const consumerChannels = consumer.data.receives?.find((receive) => receive.id === message.data.id)?.from ?? []; for (const producerChannel of producerChannels) { - const producerChannelValue = getItemsFromCollectionByIdAndSemverOrLatest( - channels, + const producerChannelValue = findInMap( + channelMap, producerChannel.id, producerChannel.version - )[0] as CollectionEntry<'channels'>; + ) as CollectionEntry<'channels'>; for (const consumerChannel of consumerChannels) { - const consumerChannelValue = getItemsFromCollectionByIdAndSemverOrLatest( - channels, + const consumerChannelValue = findInMap( + channelMap, consumerChannel.id, consumerChannel.version - )[0] as CollectionEntry<'channels'>; + ) as CollectionEntry<'channels'>; const channelChainToRender = getChannelChain(producerChannelValue, consumerChannelValue, channels); // If there is a chain between them we need to render them al @@ -416,6 +413,7 @@ export const getNodesAndEdgesForConsumedMessage = ({ currentNodes = [], target, mode = 'simple', + channelMap, }: { message: CollectionEntry; targetChannels?: { id: string; version: string }[]; @@ -424,10 +422,14 @@ export const getNodesAndEdgesForConsumedMessage = ({ currentNodes: Node[]; target: CollectionEntry<'services'>; mode?: 'simple' | 'full'; + channelMap?: Map[]>; }) => { let nodes = [] as Node[], edges = [] as any; + // Use the provided map or create one if missing + const map = channelMap || createVersionedMap(channels); + const messageId = generateIdForNode(message); const rootSourceAndTarget = { @@ -458,8 +460,8 @@ export const getNodesAndEdgesForConsumedMessage = ({ const targetMessageConfiguration = target.data.receives?.find((receive) => receive.id === message.data.id); const channelsFromMessageToTarget = targetMessageConfiguration?.from ?? []; const hydratedChannelsFromMessageToTarget = channelsFromMessageToTarget - .map((channel) => getItemsFromCollectionByIdAndSemverOrLatest(channels, channel.id, channel.version)[0]) - .filter((channel) => channel !== undefined); + .map((channel) => findInMap(map, channel.id, channel.version)) + .filter((channel): channel is CollectionEntry<'channels'> => channel !== undefined); // Now we get the producers of the message and create nodes and edges for them const producers = getProducersOfMessage(services, message); @@ -467,8 +469,6 @@ export const getNodesAndEdgesForConsumedMessage = ({ const hasProducers = producers.length > 0; const targetHasDefinedChannels = targetChannels.length > 0; - const isMessageEvent = message.collection === 'events'; - // Warning edge if no producers or target channels are defined if (!hasProducers && !targetHasDefinedChannels) { edges.push( @@ -485,11 +485,7 @@ export const getNodesAndEdgesForConsumedMessage = ({ // If the target defined channels they consume the message from, we need to create the channel nodes and edges if (targetHasDefinedChannels) { for (const targetChannel of targetChannels) { - const channel = getItemsFromCollectionByIdAndSemverOrLatest( - channels, - targetChannel.id, - targetChannel.version - )[0] as CollectionEntry<'channels'>; + const channel = findInMap(map, targetChannel.id, targetChannel.version) as CollectionEntry<'channels'>; if (!channel) { // No channe found, we just connect the message to the target directly @@ -530,8 +526,6 @@ export const getNodesAndEdgesForConsumedMessage = ({ // If we dont have any producers, we will connect the message to the channel directly if (producers.length === 0) { - const isEvent = message.collection === 'events'; - edges.push( createEdge({ id: generatedIdForEdge(message, channel), @@ -617,11 +611,7 @@ export const getNodesAndEdgesForConsumedMessage = ({ // Process each producer channel configuration for (const producerChannel of producerChannelConfiguration) { - const channel = getItemsFromCollectionByIdAndSemverOrLatest( - channels, - producerChannel.id, - producerChannel.version - )[0] as CollectionEntry<'channels'>; + const channel = findInMap(map, producerChannel.id, producerChannel.version) as CollectionEntry<'channels'>; // If we cannot find the channel in EventCatalog, we just connect the message to the target directly if (!channel) { @@ -729,6 +719,7 @@ export const getNodesAndEdgesForProducedMessage = ({ currentEdges = [], source, mode = 'simple', + channelMap, }: { message: CollectionEntry; sourceChannels?: { id: string; version: string }[]; @@ -738,10 +729,14 @@ export const getNodesAndEdgesForProducedMessage = ({ currentEdges: Edge[]; source: CollectionEntry<'services'>; mode?: 'simple' | 'full'; + channelMap?: Map[]>; }) => { let nodes = [] as Node[], edges = [] as any; + // Use provided map or create one + const map = channelMap || createVersionedMap(channels); + const messageId = generateIdForNode(message); const rootSourceAndTarget = { @@ -784,17 +779,13 @@ export const getNodesAndEdgesForProducedMessage = ({ const channelsFromSourceToMessage = sourceMessageConfiguration?.to ?? []; const hydratedChannelsFromSourceToMessage = channelsFromSourceToMessage - .map((channel) => getItemsFromCollectionByIdAndSemverOrLatest(channels, channel.id, channel.version)[0]) - .filter((channel) => channel !== undefined); + .map((channel) => findInMap(map, channel.id, channel.version)) + .filter((channel): channel is CollectionEntry<'channels'> => channel !== undefined); // If the source defined channels they send the message to, we need to create the channel nodes and edges if (sourceChannels && sourceChannels.length > 0) { for (const sourceChannel of sourceChannels) { - const channel = getItemsFromCollectionByIdAndSemverOrLatest( - channels, - sourceChannel.id, - sourceChannel.version - )[0] as CollectionEntry<'channels'>; + const channel = findInMap(map, sourceChannel.id, sourceChannel.version) as CollectionEntry<'channels'>; if (!channel) { // No channel found, we just connect the message to the source directly @@ -881,11 +872,7 @@ export const getNodesAndEdgesForProducedMessage = ({ // Process each consumer channel configuration for (const consumerChannel of consumerChannelConfiguration) { - const channel = getItemsFromCollectionByIdAndSemverOrLatest( - channels, - consumerChannel.id, - consumerChannel.version - )[0] as CollectionEntry<'channels'>; + const channel = findInMap(map, consumerChannel.id, consumerChannel.version) as CollectionEntry<'channels'>; const edgeProps = { customColor: getColorFromString(message.data.id), rootSourceAndTarget }; diff --git a/eventcatalog/src/utils/node-graphs/services-node-graph.ts b/eventcatalog/src/utils/node-graphs/services-node-graph.ts index a01ce1d1a..9749bb745 100644 --- a/eventcatalog/src/utils/node-graphs/services-node-graph.ts +++ b/eventcatalog/src/utils/node-graphs/services-node-graph.ts @@ -8,7 +8,7 @@ import { createEdge, } from '@utils/node-graphs/utils/utils'; -import { findMatchingNodes, getItemsFromCollectionByIdAndSemverOrLatest } from '@utils/collections/util'; +import { findMatchingNodes, findInMap, createVersionedMap } from '@utils/collections/util'; import { MarkerType } from '@xyflow/react'; import type { CollectionMessageTypes } from '@types'; import { getNodesAndEdgesForConsumedMessage, getNodesAndEdgesForProducedMessage } from './message-node-graph'; @@ -63,7 +63,15 @@ export const getNodesAndEdges = async ({ let nodes = [] as any, edges = [] as any; - const services = await getCollection('services'); + // Fetch all collections in parallel + const [services, events, commands, queries, channels, containers] = await Promise.all([ + getCollection('services'), + getCollection('events'), + getCollection('commands'), + getCollection('queries'), + getCollection('channels'), + getCollection('containers'), + ]); const service = services.find((service) => service.data.id === id && service.data.version === version); @@ -75,37 +83,31 @@ export const getNodesAndEdges = async ({ }; } + // Build maps for O(1) lookups + const messages = [...events, ...commands, ...queries]; + const messageMap = createVersionedMap(messages); + const containerMap = createVersionedMap(containers); + const channelMap = createVersionedMap(channels); + const receivesRaw = service?.data.receives || []; const sendsRaw = service?.data.sends || []; const writesToRaw = service?.data.writesTo || []; const readsFromRaw = service?.data.readsFrom || []; - const events = await getCollection('events'); - const commands = await getCollection('commands'); - const queries = await getCollection('queries'); - const channels = await getCollection('channels'); - const containers = await getCollection('containers'); - - const messages = [...events, ...commands, ...queries]; - const receivesHydrated = receivesRaw - .map((message) => getItemsFromCollectionByIdAndSemverOrLatest(messages, message.id, message.version)) - .flat() + .map((message) => findInMap(messageMap, message.id, message.version)) .filter((e) => e !== undefined); const sendsHydrated = sendsRaw - .map((message) => getItemsFromCollectionByIdAndSemverOrLatest(messages, message.id, message.version)) - .flat() + .map((message) => findInMap(messageMap, message.id, message.version)) .filter((e) => e !== undefined); const writesToHydrated = writesToRaw - .map((container) => getItemsFromCollectionByIdAndSemverOrLatest(containers, container.id, container.version)) - .flat() + .map((container) => findInMap(containerMap, container.id, container.version)) .filter((e) => e !== undefined); const readsFromHydrated = readsFromRaw - .map((container) => getItemsFromCollectionByIdAndSemverOrLatest(containers, container.id, container.version)) - .flat() + .map((container) => findInMap(containerMap, container.id, container.version)) .filter((e) => e !== undefined); const receives = (receivesHydrated as CollectionEntry[]) || []; @@ -130,6 +132,7 @@ export const getNodesAndEdges = async ({ target: service, mode, channels, + channelMap, }); nodes.push(...consumedMessageNodes); @@ -221,6 +224,7 @@ export const getNodesAndEdges = async ({ currentEdges: edges, mode, channels, + channelMap, }); nodes.push(...producedMessageNodes); diff --git a/eventcatalog/src/utils/page-loaders/page-data-loader.ts b/eventcatalog/src/utils/page-loaders/page-data-loader.ts index 31b1aff58..2ae2e0c21 100644 --- a/eventcatalog/src/utils/page-loaders/page-data-loader.ts +++ b/eventcatalog/src/utils/page-loaders/page-data-loader.ts @@ -1,11 +1,11 @@ import type { CollectionTypes, PageTypes } from '@types'; -import { getChannels } from '@utils/channels'; +import { getChannels } from '@utils/collections/channels'; import { getDomains } from '@utils/collections/domains'; -import { getCommands, getEvents } from '@utils/messages'; -import { getQueries } from '@utils/queries'; +import { getCommands, getEvents } from '@utils/collections/messages'; +import { getQueries } from '@utils/collections/queries'; import { getServices } from '@utils/collections/services'; import { getFlows } from '@utils/collections/flows'; -import { getEntities } from '@utils/entities'; +import { getEntities } from '@utils/collections/entities'; import { getContainers } from '@utils/collections/containers'; import type { CollectionEntry } from 'astro:content'; diff --git a/eventcatalog/src/utils/teams.ts b/eventcatalog/src/utils/teams.ts deleted file mode 100644 index ce844bba3..000000000 --- a/eventcatalog/src/utils/teams.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { getCollection } from 'astro:content'; -import type { CollectionEntry } from 'astro:content'; -import path from 'path'; - -export type Team = CollectionEntry<'teams'>; - -// Cache for build time -let cachedTeams: Team[] = []; - -export const getTeams = async (): Promise => { - if (cachedTeams.length > 0) { - return cachedTeams; - } - - // Get services that are not versioned - const teams = await getCollection('teams', (team) => { - return team.data.hidden !== true; - }); - // What do they own? - const domains = await getCollection('domains'); - // What do they own? - const services = await getCollection('services'); - // What do they own? - const events = await getCollection('events'); - const commands = await getCollection('commands'); - const queries = await getCollection('queries'); - cachedTeams = teams.map((team) => { - const ownedDomains = domains.filter((domain) => { - return domain.data.owners?.find((owner) => owner.id === team.data.id); - }); - - const ownedServices = services.filter((service) => { - return service.data.owners?.find((owner) => owner.id === team.data.id); - }); - - const ownedEvents = events.filter((event) => { - return event.data.owners?.find((owner) => owner.id === team.data.id); - }); - - const ownedCommands = commands.filter((command) => { - return command.data.owners?.find((owner) => owner.id === team.data.id); - }); - - const ownedQueries = queries.filter((query) => { - return query.data.owners?.find((owner) => owner.id === team.data.id); - }); - - return { - ...team, - data: { - ...team.data, - ownedDomains, - ownedServices, - ownedCommands, - ownedQueries, - ownedEvents, - }, - catalog: { - path: path.join(team.collection, team.id.replace('/index.mdx', '')), - filePath: path.join(process.cwd(), 'src', 'catalog-files', team.collection, team.id.replace('/index.mdx', '')), - type: 'team', - }, - }; - }); - - // order them by the name of the team - cachedTeams.sort((a, b) => { - return (a.data.name || a.data.id).localeCompare(b.data.name || b.data.id); - }); - - return cachedTeams; -}; diff --git a/eventcatalog/src/utils/users.ts b/eventcatalog/src/utils/users.ts deleted file mode 100644 index 49eaa783a..000000000 --- a/eventcatalog/src/utils/users.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { CollectionTypes } from '@types'; -import { getCollection } from 'astro:content'; -import type { CollectionEntry } from 'astro:content'; -import path from 'path'; - -export type User = CollectionEntry<'users'>; - -export const getUsers = async (): Promise => { - // Get services that are not versioned - const users = await getCollection('users', (user) => { - return user.data.hidden !== true; - }); - - // What do they own? - const domains = await getCollection('domains'); - const services = await getCollection('services'); - const events = await getCollection('events'); - const commands = await getCollection('commands'); - const queries = await getCollection('queries'); - - const teams = await getCollection('teams', (team) => { - return team.data.hidden !== true; - }); - - const mappedUsers = users.map((user) => { - const associatedTeams = teams.filter((team) => { - return team.data.members?.some((member) => member.id === user.data.id); - }); - - const ownedDomains = domains.filter((domain) => { - return domain.data.owners?.find((owner) => owner.id === user.data.id); - }); - - const isOwnedByUserOrAssociatedTeam = (item: CollectionEntry) => { - const associatedTeamsId: string[] = associatedTeams.map((team) => team.data.id); - return item.data.owners?.some((owner) => owner.id === user.data.id || associatedTeamsId.includes(owner.id)); - }; - - const ownedServices = services.filter(isOwnedByUserOrAssociatedTeam); - - const ownedEvents = events.filter(isOwnedByUserOrAssociatedTeam); - - const ownedCommands = commands.filter(isOwnedByUserOrAssociatedTeam); - - const ownedQueries = queries.filter(isOwnedByUserOrAssociatedTeam); - - return { - ...user, - data: { - ...user.data, - ownedDomains, - ownedServices, - ownedEvents, - ownedCommands, - ownedQueries, - associatedTeams, - }, - catalog: { - path: path.join(user.collection, user.id.replace('/index.mdx', '')), - filePath: path.join(process.cwd(), 'src', 'catalog-files', user.collection, user.id.replace('/index.mdx', '')), - type: 'user', - }, - }; - }); - - // order them by the name of the user - mappedUsers.sort((a, b) => { - return (a.data.name || a.data.id).localeCompare(b.data.name || b.data.id); - }); - - return mappedUsers; -}; diff --git a/eventcatalog/tailwind.config.mjs b/eventcatalog/tailwind.config.mjs index b498e6254..add727541 100644 --- a/eventcatalog/tailwind.config.mjs +++ b/eventcatalog/tailwind.config.mjs @@ -34,6 +34,20 @@ export default { }, ...theme, }, + keyframes: { + 'progress-bar': { + '0%': { transform: 'translateX(-100%)' }, + '100%': { transform: 'translateX(100%)' }, + }, + 'progress-bar-reverse': { + '0%': { transform: 'translateX(100%)' }, + '100%': { transform: 'translateX(-100%)' }, + }, + }, + animation: { + 'progress-bar': 'progress-bar 2s linear infinite', + 'progress-bar-reverse': 'progress-bar-reverse 2s linear infinite', + }, }, }, safelist: [ diff --git a/eventcatalog/tsconfig.json b/eventcatalog/tsconfig.json index 602a1429d..343352f84 100644 --- a/eventcatalog/tsconfig.json +++ b/eventcatalog/tsconfig.json @@ -15,7 +15,8 @@ "@layouts/*": ["src/layouts/*"], "@enterprise/*": ["src/enterprise/*"], "@ai/*": ["src/generated-ai/*"], - "auth:config": ["./auth.config.ts"] + "auth:config": ["./auth.config.ts"], + "@stores/*": ["src/stores/*"] }, "jsx": "react-jsx", "jsxImportSource": "react", diff --git a/examples/default/domains/E-Commerce/index.mdx b/examples/default/domains/E-Commerce/index.mdx index b2c265bb9..17c31847d 100644 --- a/examples/default/domains/E-Commerce/index.mdx +++ b/examples/default/domains/E-Commerce/index.mdx @@ -19,24 +19,6 @@ badges: backgroundColor: yellow textColor: yellow icon: ShieldCheckIcon -resourceGroups: - - id: related-resources - title: Core FlowMart Services - items: - - id: InventoryService - type: service - - id: OrdersService - type: service - - id: NotificationService - type: service - - id: ShippingService - type: service - - id: CustomerService - type: service - - id: PaymentService - type: service - - id: AnalyticsService - type: service attachments: - url: https://example.com/adr/001 title: ADR-001 - Use Kafka for asynchronous messaging @@ -53,6 +35,9 @@ attachments: description: The C4 diagram context of the E-Commerce system type: 'diagrams' icon: FileBoxIcon +repository: + language: TypeScript + url: 'https://github.com/event-catalog/pretend-e-commerce-domain' --- import Footer from '@catalog/components/footer.astro'; @@ -204,13 +189,6 @@ Customers ||--o{ Subscription : subscribes ``` -## Target Architecture (Event Storming Results) - -Our target architecture was defined through collaborative event storming sessions with product, engineering, and business stakeholders. This represents our vision for FlowMart's commerce capabilities. - - - - ### Order Processing Flow ```mermaid @@ -246,17 +224,6 @@ Secure, multi-provider payment processing with fraud detection: -## Core Services - -These services form the backbone of FlowMart's e-commerce operations: - - - ## Performance SLAs - Order Processing: < 2 seconds diff --git a/examples/default/domains/E-Commerce/subdomains/Orders/index.mdx b/examples/default/domains/E-Commerce/subdomains/Orders/index.mdx index 992dcbf07..a9a8d42c4 100644 --- a/examples/default/domains/E-Commerce/subdomains/Orders/index.mdx +++ b/examples/default/domains/E-Commerce/subdomains/Orders/index.mdx @@ -21,18 +21,6 @@ entities: - id: Customer - id: ShoppingCart - id: CartItem -resourceGroups: - - id: related-resources - title: Core resources - items: - - id: InventoryService - type: service - - id: OrdersService - type: service - - id: NotificationService - type: service - - id: ShippingService - type: service --- import Footer from '@catalog/components/footer.astro'; diff --git a/examples/default/domains/E-Commerce/subdomains/Orders/services/InventoryService/containers/inventory-db/index.mdx b/examples/default/domains/E-Commerce/subdomains/Orders/services/InventoryService/containers/inventory-db/index.mdx index 0974eafbf..8f44e9a9c 100644 --- a/examples/default/domains/E-Commerce/subdomains/Orders/services/InventoryService/containers/inventory-db/index.mdx +++ b/examples/default/domains/E-Commerce/subdomains/Orders/services/InventoryService/containers/inventory-db/index.mdx @@ -14,7 +14,7 @@ summary: Authoritative database for product inventory levels, warehouse stock, a -### What is this? +## What is this? Inventory DB is the system of record for real-time inventory tracking across multiple warehouses and fulfillment centers. It maintains accurate stock levels, handles inventory reservations, and tracks all inventory movements (receipts, shipments, adjustments). ### What does it store? diff --git a/examples/default/domains/E-Commerce/subdomains/Payment/index.mdx b/examples/default/domains/E-Commerce/subdomains/Payment/index.mdx index d17da70e7..e88594ea0 100644 --- a/examples/default/domains/E-Commerce/subdomains/Payment/index.mdx +++ b/examples/default/domains/E-Commerce/subdomains/Payment/index.mdx @@ -19,6 +19,8 @@ entities: - id: PaymentMethod - id: Transaction - id: Address +flows: + - id: PaymentFlow badges: - content: Subdomain backgroundColor: blue diff --git a/examples/default/domains/E-Commerce/subdomains/Subscriptions/flows/CancelSubscription/index.mdx b/examples/default/domains/E-Commerce/subdomains/Subscriptions/flows/CancelSubscription/index.mdx index 772757393..bcf359d93 100644 --- a/examples/default/domains/E-Commerce/subdomains/Subscriptions/flows/CancelSubscription/index.mdx +++ b/examples/default/domains/E-Commerce/subdomains/Subscriptions/flows/CancelSubscription/index.mdx @@ -1,5 +1,5 @@ --- -id: "CancelSubscription" +id: CancelSubscription name: "User Cancels Subscription" version: "1.0.0" summary: "Flow for when a user has cancelled a subscription" diff --git a/examples/default/domains/E-Commerce/subdomains/Subscriptions/flows/SubscriptionRenewed/index.mdx b/examples/default/domains/E-Commerce/subdomains/Subscriptions/flows/SubscriptionRenewed/index.mdx index 568329293..d51c53819 100644 --- a/examples/default/domains/E-Commerce/subdomains/Subscriptions/flows/SubscriptionRenewed/index.mdx +++ b/examples/default/domains/E-Commerce/subdomains/Subscriptions/flows/SubscriptionRenewed/index.mdx @@ -1,5 +1,5 @@ --- -id: "SubscriptionRenewed" +id: SubscriptionRenewed name: "Subscription Renewal Flow" version: "1.0.0" summary: "Business flow for automatic subscription renewals and related processes" diff --git a/examples/default/domains/E-Commerce/subdomains/Subscriptions/index.mdx b/examples/default/domains/E-Commerce/subdomains/Subscriptions/index.mdx index ce7d3ae19..2dabf6994 100644 --- a/examples/default/domains/E-Commerce/subdomains/Subscriptions/index.mdx +++ b/examples/default/domains/E-Commerce/subdomains/Subscriptions/index.mdx @@ -16,7 +16,9 @@ badges: - content: Subdomain backgroundColor: blue textColor: blue - +flows: + - id: SubscriptionRenewed + - id: CancelSubscription --- ## Overview diff --git a/examples/default/domains/E-Commerce/subdomains/Subscriptions/services/BillingService/index.mdx b/examples/default/domains/E-Commerce/subdomains/Subscriptions/services/BillingService/index.mdx index 82e3eebdb..d29bfe1ae 100644 --- a/examples/default/domains/E-Commerce/subdomains/Subscriptions/services/BillingService/index.mdx +++ b/examples/default/domains/E-Commerce/subdomains/Subscriptions/services/BillingService/index.mdx @@ -33,6 +33,9 @@ specifications: name: GraphQL API readsFrom: - id: order-metadata-store +flows: + - id: SubscriptionRenewed + - id: CancelSubscription --- import Footer from '@catalog/components/footer.astro' diff --git a/examples/default/eventcatalog.config.js b/examples/default/eventcatalog.config.js index add492eb2..5ddf3e775 100644 --- a/examples/default/eventcatalog.config.js +++ b/examples/default/eventcatalog.config.js @@ -32,11 +32,28 @@ export default { llmsTxt: { enabled: true, }, - // chat: { - // enabled: true, - // provider: 'openai', - // model: 'gpt-4.1', - // }, + navigation: { + pages: [ + { + type: 'group', + title: 'Core Domain', + icon: 'Boxes', + pages: [ + 'domain:E-Commerce', + ] + }, + { + type: 'group', + title: 'Supporting Domains', + icon: 'Boxes', + pages: [ + 'domain:Payment', + 'domain:Subscriptions', + ] + }, + 'list:all', + ] + }, customDocs: { sidebar: [ { diff --git a/package.json b/package.json index 5f6dd269c..df2604925 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "dev": "astro dev", "build:bin": "tsup", "prepublishOnly": "pnpm run build:bin", - "test": "vitest", + "test": "cross-env DISABLE_EVENTCATALOG_CACHE=true vitest", "test:ci": "node scripts/ci/test.js", "test:e2e": "playwright test", "start": "astro dev", @@ -69,6 +69,7 @@ "@heroicons/react": "^2.1.3", "@iconify-json/logos": "^1.2.4", "@mermaid-js/layout-elk": "^0.2.0", + "@nanostores/react": "^1.0.0", "@parcel/watcher": "^2.4.1", "@radix-ui/react-context-menu": "^2.2.6", "@radix-ui/react-dialog": "^1.1.6", @@ -80,7 +81,7 @@ "@tanstack/react-table": "^8.17.3", "@xyflow/react": "^12.3.6", "ai": "^5.0.60", - "astro": "^5.16.0", + "astro": "^5.16.4", "astro-compress": "^2.3.8", "astro-expressive-code": "^0.41.3", "astro-seo": "^0.8.4", @@ -107,6 +108,7 @@ "lucide-react": "^0.453.0", "marked": "^15.0.6", "mermaid": "^11.4.1", + "nanostores": "^1.1.0", "pagefind": "^1.3.0", "pako": "^2.1.0", "react": "^18.3.1", @@ -123,13 +125,14 @@ "semver": "7.6.3", "shelljs": "^0.8.5", "tailwindcss": "^3.4.3", + "tw-animate-css": "^1.4.0", "typescript": "^5.4.5", "unist-util-visit": "^5.0.0", "update-notifier": "^7.3.1", "uuid": "^10.0.0" }, "devDependencies": { - "@astrojs/check": "^0.9.5", + "@astrojs/check": "^0.9.6", "@changesets/cli": "^2.27.5", "@playwright/test": "^1.48.1", "@types/dagre": "^0.7.52", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e9b9b77c..7afda7b38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,10 +25,10 @@ importers: version: 6.3.9 '@astrojs/mdx': specifier: ^4.3.12 - version: 4.3.12(astro@5.16.0(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1)) + version: 4.3.12(astro@5.16.4(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1)) '@astrojs/node': specifier: ^9.5.1 - version: 9.5.1(astro@5.16.0(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1)) + version: 9.5.1(astro@5.16.4(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1)) '@astrojs/react': specifier: ^4.4.2 version: 4.4.2(@types/node@20.19.19)(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(jiti@1.21.7)(lightningcss@1.29.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(terser@5.39.0)(yaml@2.8.1) @@ -37,7 +37,7 @@ importers: version: 4.0.14 '@astrojs/tailwind': specifier: ^6.0.2 - version: 6.0.2(astro@5.16.0(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(yaml@2.8.1)) + version: 6.0.2(astro@5.16.4(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(yaml@2.8.1)) '@asyncapi/avro-schema-parser': specifier: 3.0.24 version: 3.0.24 @@ -80,6 +80,9 @@ importers: '@mermaid-js/layout-elk': specifier: ^0.2.0 version: 0.2.0(mermaid@11.12.0) + '@nanostores/react': + specifier: ^1.0.0 + version: 1.0.0(nanostores@1.1.0)(react@18.3.1) '@parcel/watcher': specifier: ^2.4.1 version: 2.5.1 @@ -114,20 +117,20 @@ importers: specifier: ^5.0.60 version: 5.0.62(zod@3.25.76) astro: - specifier: ^5.16.0 - version: 5.16.0(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1) + specifier: ^5.16.4 + version: 5.16.4(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1) astro-compress: specifier: ^2.3.8 version: 2.3.8(@types/node@20.19.19)(jiti@1.21.7)(rollup@4.52.4)(typescript@5.9.3)(yaml@2.8.1) astro-expressive-code: specifier: ^0.41.3 - version: 0.41.3(astro@5.16.0(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1)) + version: 0.41.3(astro@5.16.4(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1)) astro-seo: specifier: ^0.8.4 version: 0.8.4(prettier-plugin-astro@0.14.1)(prettier@3.6.2)(typescript@5.9.3) auth-astro: specifier: ^4.2.0 - version: 4.2.0(@auth/core@0.37.4)(astro@5.16.0(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1)) + version: 4.2.0(@auth/core@0.37.4)(astro@5.16.4(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1)) axios: specifier: ^1.7.7 version: 1.12.2 @@ -194,6 +197,9 @@ importers: mermaid: specifier: ^11.4.1 version: 11.12.0 + nanostores: + specifier: ^1.1.0 + version: 1.1.0 pagefind: specifier: ^1.3.0 version: 1.4.0 @@ -242,6 +248,9 @@ importers: tailwindcss: specifier: ^3.4.3 version: 3.4.18(yaml@2.8.1) + tw-animate-css: + specifier: ^1.4.0 + version: 1.4.0 typescript: specifier: ^5.4.5 version: 5.9.3 @@ -256,8 +265,8 @@ importers: version: 10.0.0 devDependencies: '@astrojs/check': - specifier: ^0.9.5 - version: 0.9.5(prettier-plugin-astro@0.14.1)(prettier@3.6.2)(typescript@5.9.3) + specifier: ^0.9.6 + version: 0.9.6(prettier-plugin-astro@0.14.1)(prettier@3.6.2)(typescript@5.9.3) '@changesets/cli': specifier: ^2.27.5 version: 2.29.7(@types/node@20.19.19) @@ -402,8 +411,8 @@ packages: peerDependencies: typescript: ^5.0.0 - '@astrojs/check@0.9.5': - resolution: {integrity: sha512-88vc8n2eJ1Oua74yXSGo/8ABMeypfQPGEzuoAx2awL9Ju8cE6tZ2Rz9jVx5hIExHK5gKVhpxfZj4WXm7e32g1w==} + '@astrojs/check@0.9.6': + resolution: {integrity: sha512-jlaEu5SxvSgmfGIFfNgcn5/f+29H61NJzEMfAZ82Xopr4XBchXB1GVlcJsE+elUlsYSbXlptZLX+JMG3b/wZEA==} hasBin: true peerDependencies: typescript: ^5.0.0 @@ -426,6 +435,18 @@ packages: prettier-plugin-astro: optional: true + '@astrojs/language-server@2.16.2': + resolution: {integrity: sha512-J3hVx/mFi3FwEzKf8ExYXQNERogD6RXswtbU+TyrxoXRBiQoBO5ooo7/lRWJ+rlUKUd7+rziMPI9jYB7TRlh0w==} + hasBin: true + peerDependencies: + prettier: ^3.0.0 + prettier-plugin-astro: '>=0.11.0' + peerDependenciesMeta: + prettier: + optional: true + prettier-plugin-astro: + optional: true + '@astrojs/markdown-remark@6.3.9': resolution: {integrity: sha512-hX2cLC/KW74Io1zIbn92kI482j9J7LleBLGCVU9EP3BeH5MVrnFawOnqD0t/q6D1Z+ZNeQG2gNKMslCcO36wng==} @@ -771,6 +792,9 @@ packages: '@emmetio/css-parser@0.4.0': resolution: {integrity: sha512-z7wkxRSZgrQHXVzObGkXG+Vmj3uRlpM11oCZ9pbaz0nFejvCDmAiNDpY75+wgXOcffKpj4rzGtwGaZxfJKsJxw==} + '@emmetio/css-parser@0.4.1': + resolution: {integrity: sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ==} + '@emmetio/html-matcher@1.3.0': resolution: {integrity: sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ==} @@ -1563,6 +1587,13 @@ packages: '@mermaid-js/parser@0.6.3': resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==} + '@nanostores/react@1.0.0': + resolution: {integrity: sha512-eDduyNy+lbQJMg6XxZ/YssQqF6b4OXMFEZMYKPJCCmBevp1lg0g+4ZRi94qGHirMtsNfAWKNwsjOhC+q1gvC+A==} + engines: {node: ^20.0.0 || >=22.0.0} + peerDependencies: + nanostores: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^1.0.0 + react: '>=18.0.0' + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -3058,8 +3089,8 @@ packages: astro-seo@0.8.4: resolution: {integrity: sha512-Ou1vzQSXAxa0K8rtNtXNvSpYqOGEgMhh0immMxJeXmbVZac3UKCNWAoXWyOQDFYsZvBugCRSg0N1phBqPMVgCw==} - astro@5.16.0: - resolution: {integrity: sha512-GaDRs2Mngpw3dr2vc085GnORh98NiXxwIjg/EoQQQl/icZt3Z7s0BRsYHDZ8swkZbOA6wZsqWJdrNirl+iKcDg==} + astro@5.16.4: + resolution: {integrity: sha512-rgXI/8/tnO3Y9tfAaUyg/8beKhlIMltbiC8Q6jCoAfEidOyaue4KYKzbe0gJIb6qEdEaG3Kf3BY3EOSLkbWOLg==} engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0'} hasBin: true @@ -5576,6 +5607,10 @@ packages: engines: {node: ^18 || >=20} hasBin: true + nanostores@1.1.0: + resolution: {integrity: sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA==} + engines: {node: ^20.0.0 || >=22.0.0} + neotraverse@0.6.18: resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} engines: {node: '>= 10'} @@ -5663,9 +5698,6 @@ packages: resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} engines: {node: '>= 0.4'} - ofetch@1.4.1: - resolution: {integrity: sha512-QZj2DfGplQAr2oj9KzceK9Hwz6Whxazmn85yYeVuS3u9XTMOGMRx0kO95MQ+vLsj/S/NwBDMMLU5hpxvI6Tklw==} - ofetch@1.5.1: resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} @@ -6884,6 +6916,9 @@ packages: typescript: optional: true + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + type-check@0.3.2: resolution: {integrity: sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==} engines: {node: '>= 0.8.0'} @@ -7333,6 +7368,14 @@ packages: '@volar/language-service': optional: true + volar-service-css@0.0.67: + resolution: {integrity: sha512-zV7C6enn9T9tuvQ6iSUyYEs34iPXR69Pf9YYWpbFYPWzVs22w96BtE8p04XYXbmjU6unt5oFt+iLL77bMB5fhA==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + volar-service-emmet@0.0.62: resolution: {integrity: sha512-U4dxWDBWz7Pi4plpbXf4J4Z/ss6kBO3TYrACxWNsE29abu75QzVS0paxDDhI6bhqpbDFXlpsDhZ9aXVFpnfGRQ==} peerDependencies: @@ -7341,6 +7384,14 @@ packages: '@volar/language-service': optional: true + volar-service-emmet@0.0.67: + resolution: {integrity: sha512-UDBL5x7KptmuJZNCCXMlCndMhFult/tj+9jXq3FH1ZGS1E4M/1U5hC06pg1c6e4kn+vnR6bqmvX0vIhL4f98+A==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + volar-service-html@0.0.62: resolution: {integrity: sha512-Zw01aJsZRh4GTGUjveyfEzEqpULQUdQH79KNEiKVYHZyuGtdBRYCHlrus1sueSNMxwwkuF5WnOHfvBzafs8yyQ==} peerDependencies: @@ -7349,6 +7400,14 @@ packages: '@volar/language-service': optional: true + volar-service-html@0.0.67: + resolution: {integrity: sha512-ljREMF79JbcjNvObiv69HK2HCl5UT7WTD10zi6CRFUHMbPfiF2UZ42HGLsEGSzaHGZz6H4IFjSS/qfENRLUviQ==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + volar-service-prettier@0.0.62: resolution: {integrity: sha512-h2yk1RqRTE+vkYZaI9KYuwpDfOQRrTEMvoHol0yW4GFKc75wWQRrb5n/5abDrzMPrkQbSip8JH2AXbvrRtYh4w==} peerDependencies: @@ -7360,6 +7419,17 @@ packages: prettier: optional: true + volar-service-prettier@0.0.67: + resolution: {integrity: sha512-B4KnPJPNWFTkEDa6Fn08i5PpO6T1CecmLLTFZoXz2eI4Fxwba/3nDaaVSsEP7e/vEe+U5YqV9fBzayJT71G5xg==} + peerDependencies: + '@volar/language-service': ~2.4.0 + prettier: ^2.2 || ^3.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + prettier: + optional: true + volar-service-typescript-twoslash-queries@0.0.62: resolution: {integrity: sha512-KxFt4zydyJYYI0kFAcWPTh4u0Ha36TASPZkAnNY784GtgajerUqM80nX/W1d0wVhmcOFfAxkVsf/Ed+tiYU7ng==} peerDependencies: @@ -7368,6 +7438,14 @@ packages: '@volar/language-service': optional: true + volar-service-typescript-twoslash-queries@0.0.67: + resolution: {integrity: sha512-LD2R7WivDYp1SPgZrxx/0222xVTitDjm36oKo5+bfYG5kEgnw+BOPVHdwmvpJKg/RfssfxDI1ouwD4XkEDEfbA==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + volar-service-typescript@0.0.62: resolution: {integrity: sha512-p7MPi71q7KOsH0eAbZwPBiKPp9B2+qrdHAd6VY5oTo9BUXatsOAdakTm9Yf0DUj6uWBAaOT01BSeVOPwucMV1g==} peerDependencies: @@ -7376,6 +7454,14 @@ packages: '@volar/language-service': optional: true + volar-service-typescript@0.0.67: + resolution: {integrity: sha512-rfQBy36Rm1PU9vLWHk8BYJ4r2j/CI024vocJcH4Nb6K2RTc2Irmw6UOVY5DdGiPRV5r+e10wLMK5njj/EcL8sA==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + volar-service-yaml@0.0.62: resolution: {integrity: sha512-k7gvv7sk3wa+nGll3MaSKyjwQsJjIGCHFjVkl3wjaSP2nouKyn9aokGmqjrl39mi88Oy49giog2GkZH526wjig==} peerDependencies: @@ -7384,6 +7470,14 @@ packages: '@volar/language-service': optional: true + volar-service-yaml@0.0.67: + resolution: {integrity: sha512-jkdP/RF6wPIXEE3Ktnd81oJPn7aAvnVSiaqQHThC2Hrvo6xd9pEcqtbBUI+YfqVgvcMtXAkbtNO61K2GPhAiuA==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + vscode-css-languageservice@6.3.8: resolution: {integrity: sha512-dBk/9ullEjIMbfSYAohGpDOisOVU1x2MQHOeU12ohGJQI7+r0PCimBwaa/pWpxl/vH4f7ibrBfxIZY3anGmHKQ==} @@ -7608,10 +7702,19 @@ packages: resolution: {integrity: sha512-N47AqBDCMQmh6mBLmI6oqxryHRzi33aPFPsJhYy3VTUGCdLHYjGh4FZzpUjRlphaADBBkDmnkM/++KNIOHi5Rw==} hasBin: true + yaml-language-server@1.19.2: + resolution: {integrity: sha512-9F3myNmJzUN/679jycdMxqtydPSDRAarSj3wPiF7pchEPnO9Dg07Oc+gIYLqXR4L+g+FSEVXXv2+mr54StLFOg==} + hasBin: true + yaml@2.2.2: resolution: {integrity: sha512-CBKFWExMn46Foo4cldiChEzn7S7SRV+wqiluAb6xmueD/fGyRHIhX8m14vVGgeFWjN540nKCNVj6P21eQjgTuA==} engines: {node: '>= 14'} + yaml@2.7.1: + resolution: {integrity: sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==} + engines: {node: '>= 14'} + hasBin: true + yaml@2.8.1: resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==} engines: {node: '>= 14.6'} @@ -7640,10 +7743,10 @@ packages: zhead@2.2.4: resolution: {integrity: sha512-8F0OI5dpWIA5IGG5NHUg9staDwz/ZPxZtvGVf01j7vHqSyZ0raHY+78atOVxRqb73AotX22uV1pXt3gYSstGag==} - zod-to-json-schema@3.24.6: - resolution: {integrity: sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==} + zod-to-json-schema@3.25.0: + resolution: {integrity: sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==} peerDependencies: - zod: ^3.24.1 + zod: ^3.25 || ^4 zod-to-ts@1.2.0: resolution: {integrity: sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA==} @@ -7759,9 +7862,9 @@ snapshots: - prettier - prettier-plugin-astro - '@astrojs/check@0.9.5(prettier-plugin-astro@0.14.1)(prettier@3.6.2)(typescript@5.9.3)': + '@astrojs/check@0.9.6(prettier-plugin-astro@0.14.1)(prettier@3.6.2)(typescript@5.9.3)': dependencies: - '@astrojs/language-server': 2.15.4(prettier-plugin-astro@0.14.1)(prettier@3.6.2)(typescript@5.9.3) + '@astrojs/language-server': 2.16.2(prettier-plugin-astro@0.14.1)(prettier@3.6.2)(typescript@5.9.3) chokidar: 4.0.3 kleur: 4.1.5 typescript: 5.9.3 @@ -7800,6 +7903,32 @@ snapshots: transitivePeerDependencies: - typescript + '@astrojs/language-server@2.16.2(prettier-plugin-astro@0.14.1)(prettier@3.6.2)(typescript@5.9.3)': + dependencies: + '@astrojs/compiler': 2.13.0 + '@astrojs/yaml2ts': 0.2.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@volar/kit': 2.4.23(typescript@5.9.3) + '@volar/language-core': 2.4.23 + '@volar/language-server': 2.4.23 + '@volar/language-service': 2.4.23 + fast-glob: 3.3.3 + muggle-string: 0.4.1 + volar-service-css: 0.0.67(@volar/language-service@2.4.23) + volar-service-emmet: 0.0.67(@volar/language-service@2.4.23) + volar-service-html: 0.0.67(@volar/language-service@2.4.23) + volar-service-prettier: 0.0.67(@volar/language-service@2.4.23)(prettier@3.6.2) + volar-service-typescript: 0.0.67(@volar/language-service@2.4.23) + volar-service-typescript-twoslash-queries: 0.0.67(@volar/language-service@2.4.23) + volar-service-yaml: 0.0.67(@volar/language-service@2.4.23) + vscode-html-languageservice: 5.5.2 + vscode-uri: 3.1.0 + optionalDependencies: + prettier: 3.6.2 + prettier-plugin-astro: 0.14.1 + transitivePeerDependencies: + - typescript + '@astrojs/markdown-remark@6.3.9': dependencies: '@astrojs/internal-helpers': 0.7.5 @@ -7826,12 +7955,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@astrojs/mdx@4.3.12(astro@5.16.0(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1))': + '@astrojs/mdx@4.3.12(astro@5.16.4(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1))': dependencies: '@astrojs/markdown-remark': 6.3.9 '@mdx-js/mdx': 3.1.1 acorn: 8.15.0 - astro: 5.16.0(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1) + astro: 5.16.4(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1) es-module-lexer: 1.7.0 estree-util-visit: 2.0.0 hast-util-to-html: 9.0.5 @@ -7845,10 +7974,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@astrojs/node@9.5.1(astro@5.16.0(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1))': + '@astrojs/node@9.5.1(astro@5.16.4(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1))': dependencies: '@astrojs/internal-helpers': 0.7.5 - astro: 5.16.0(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1) + astro: 5.16.4(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1) send: 1.2.0 server-destroy: 1.0.1 transitivePeerDependencies: @@ -7886,9 +8015,9 @@ snapshots: fast-xml-parser: 5.3.0 piccolore: 0.1.3 - '@astrojs/tailwind@6.0.2(astro@5.16.0(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(yaml@2.8.1))': + '@astrojs/tailwind@6.0.2(astro@5.16.4(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(yaml@2.8.1))': dependencies: - astro: 5.16.0(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1) + astro: 5.16.4(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1) autoprefixer: 10.4.21(postcss@8.5.6) postcss: 8.5.6 postcss-load-config: 4.0.2(postcss@8.5.6) @@ -8428,6 +8557,11 @@ snapshots: '@emmetio/stream-reader': 2.2.0 '@emmetio/stream-reader-utils': 0.1.0 + '@emmetio/css-parser@0.4.1': + dependencies: + '@emmetio/stream-reader': 2.2.0 + '@emmetio/stream-reader-utils': 0.1.0 + '@emmetio/html-matcher@1.3.0': dependencies: '@emmetio/scanner': 1.0.4 @@ -9053,7 +9187,7 @@ snapshots: p-retry: 4.6.2 uuid: 10.0.0 zod: 3.25.76 - zod-to-json-schema: 3.24.6(zod@3.25.76) + zod-to-json-schema: 3.25.0(zod@3.25.76) transitivePeerDependencies: - '@opentelemetry/api' - '@opentelemetry/exporter-trace-otlp-proto' @@ -9178,6 +9312,11 @@ snapshots: dependencies: langium: 3.3.1 + '@nanostores/react@1.0.0(nanostores@1.1.0)(react@18.3.1)': + dependencies: + nanostores: 1.1.0 + react: 18.3.1 + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -10951,7 +11090,7 @@ snapshots: '@playform/pipe': 0.1.3 '@types/csso': 5.0.4 '@types/html-minifier-terser': 7.0.2 - astro: 5.16.0(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1) + astro: 5.16.4(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1) commander: 13.1.0 csso: 5.0.5 deepmerge-ts: 7.1.5 @@ -10995,9 +11134,9 @@ snapshots: - uploadthing - yaml - astro-expressive-code@0.41.3(astro@5.16.0(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1)): + astro-expressive-code@0.41.3(astro@5.16.4(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1)): dependencies: - astro: 5.16.0(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1) + astro: 5.16.4(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1) rehype-expressive-code: 0.41.3 astro-seo@0.8.4(prettier-plugin-astro@0.14.1)(prettier@3.6.2)(typescript@5.9.3): @@ -11008,7 +11147,7 @@ snapshots: - prettier-plugin-astro - typescript - astro@5.16.0(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1): + astro@5.16.4(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1): dependencies: '@astrojs/compiler': 2.13.0 '@astrojs/internal-helpers': 0.7.5 @@ -11071,7 +11210,7 @@ snapshots: yargs-parser: 21.1.1 yocto-spinner: 0.2.3 zod: 3.25.76 - zod-to-json-schema: 3.24.6(zod@3.25.76) + zod-to-json-schema: 3.25.0(zod@3.25.76) zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76) optionalDependencies: sharp: 0.34.4 @@ -11119,10 +11258,10 @@ snapshots: stubborn-fs: 1.2.5 when-exit: 2.1.4 - auth-astro@4.2.0(@auth/core@0.37.4)(astro@5.16.0(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1)): + auth-astro@4.2.0(@auth/core@0.37.4)(astro@5.16.4(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1)): dependencies: '@auth/core': 0.37.4 - astro: 5.16.0(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1) + astro: 5.16.4(@types/node@20.19.19)(jiti@1.21.7)(lightningcss@1.29.3)(rollup@4.52.4)(terser@5.39.0)(typescript@5.9.3)(yaml@2.8.1) set-cookie-parser: 2.7.1 autoprefixer@10.4.21(postcss@8.5.6): @@ -14228,6 +14367,8 @@ snapshots: nanoid@5.1.6: {} + nanostores@1.1.0: {} + neotraverse@0.6.18: {} nimma@0.2.3: @@ -14298,12 +14439,6 @@ snapshots: has-symbols: 1.1.0 object-keys: 1.1.1 - ofetch@1.4.1: - dependencies: - destr: 2.0.5 - node-fetch-native: 1.6.7 - ufo: 1.6.1 - ofetch@1.5.1: dependencies: destr: 2.0.5 @@ -15779,6 +15914,8 @@ snapshots: - tsx - yaml + tw-animate-css@1.4.0: {} + type-check@0.3.2: dependencies: prelude-ls: 1.1.2 @@ -15891,7 +16028,7 @@ snapshots: unifont@0.6.0: dependencies: css-tree: 3.1.0 - ofetch: 1.4.1 + ofetch: 1.5.1 ohash: 2.0.11 unist-util-find-after@5.0.0: @@ -16189,6 +16326,14 @@ snapshots: optionalDependencies: '@volar/language-service': 2.4.23 + volar-service-css@0.0.67(@volar/language-service@2.4.23): + dependencies: + vscode-css-languageservice: 6.3.8 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.23 + volar-service-emmet@0.0.62(@volar/language-service@2.4.23): dependencies: '@emmetio/css-parser': 0.4.0 @@ -16198,6 +16343,15 @@ snapshots: optionalDependencies: '@volar/language-service': 2.4.23 + volar-service-emmet@0.0.67(@volar/language-service@2.4.23): + dependencies: + '@emmetio/css-parser': 0.4.1 + '@emmetio/html-matcher': 1.3.0 + '@vscode/emmet-helper': 2.11.0 + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.23 + volar-service-html@0.0.62(@volar/language-service@2.4.23): dependencies: vscode-html-languageservice: 5.5.2 @@ -16206,6 +16360,14 @@ snapshots: optionalDependencies: '@volar/language-service': 2.4.23 + volar-service-html@0.0.67(@volar/language-service@2.4.23): + dependencies: + vscode-html-languageservice: 5.5.2 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.23 + volar-service-prettier@0.0.62(@volar/language-service@2.4.23)(prettier@3.6.2): dependencies: vscode-uri: 3.1.0 @@ -16213,12 +16375,25 @@ snapshots: '@volar/language-service': 2.4.23 prettier: 3.6.2 + volar-service-prettier@0.0.67(@volar/language-service@2.4.23)(prettier@3.6.2): + dependencies: + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.23 + prettier: 3.6.2 + volar-service-typescript-twoslash-queries@0.0.62(@volar/language-service@2.4.23): dependencies: vscode-uri: 3.1.0 optionalDependencies: '@volar/language-service': 2.4.23 + volar-service-typescript-twoslash-queries@0.0.67(@volar/language-service@2.4.23): + dependencies: + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.23 + volar-service-typescript@0.0.62(@volar/language-service@2.4.23): dependencies: path-browserify: 1.0.1 @@ -16230,6 +16405,17 @@ snapshots: optionalDependencies: '@volar/language-service': 2.4.23 + volar-service-typescript@0.0.67(@volar/language-service@2.4.23): + dependencies: + path-browserify: 1.0.1 + semver: 7.6.3 + typescript-auto-import-cache: 0.3.6 + vscode-languageserver-textdocument: 1.0.12 + vscode-nls: 5.2.0 + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.23 + volar-service-yaml@0.0.62(@volar/language-service@2.4.23): dependencies: vscode-uri: 3.1.0 @@ -16237,6 +16423,13 @@ snapshots: optionalDependencies: '@volar/language-service': 2.4.23 + volar-service-yaml@0.0.67(@volar/language-service@2.4.23): + dependencies: + vscode-uri: 3.1.0 + yaml-language-server: 1.19.2 + optionalDependencies: + '@volar/language-service': 2.4.23 + vscode-css-languageservice@6.3.8: dependencies: '@vscode/l10n': 0.0.18 @@ -16469,8 +16662,25 @@ snapshots: optionalDependencies: prettier: 2.8.7 + yaml-language-server@1.19.2: + dependencies: + '@vscode/l10n': 0.0.18 + ajv: 8.17.1 + ajv-draft-04: 1.0.0(ajv@8.17.1) + lodash: 4.17.21 + prettier: 3.6.2 + request-light: 0.5.8 + vscode-json-languageservice: 4.1.8 + vscode-languageserver: 9.0.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-uri: 3.1.0 + yaml: 2.7.1 + yaml@2.2.2: {} + yaml@2.7.1: {} + yaml@2.8.1: {} yargs-parser@21.1.1: {} @@ -16495,7 +16705,7 @@ snapshots: zhead@2.2.4: {} - zod-to-json-schema@3.24.6(zod@3.25.76): + zod-to-json-schema@3.25.0(zod@3.25.76): dependencies: zod: 3.25.76 diff --git a/src/eventcatalog.config.ts b/src/eventcatalog.config.ts index f0c1eef0b..2ae537756 100644 --- a/src/eventcatalog.config.ts +++ b/src/eventcatalog.config.ts @@ -44,6 +44,13 @@ type TableConfiguration = { }; }; +type PagesConfiguration = { + type: 'item' | 'group'; + title: string; + icon?: string; + pages?: string[]; +}; + export interface Config { title: string; tagline: false; @@ -75,6 +82,9 @@ export interface Config { mdxOptimize?: boolean; compress?: boolean; sidebar?: SideBarConfig[]; + navigation?: { + pages: PagesConfiguration[]; + }; docs: { sidebar: { type?: 'TREE_VIEW' | 'LIST_VIEW'; diff --git a/src/eventcatalog.ts b/src/eventcatalog.ts index 8cd39855f..6f3051441 100755 --- a/src/eventcatalog.ts +++ b/src/eventcatalog.ts @@ -313,28 +313,28 @@ program } ); - // Not server rendered, then we need to index the site - if (!isServerOutput) { - const outDir = await getProjectOutDir(); - - const windowsCommand = `npx -y pagefind --site ${outDir}`; - const unixCommand = `npx -y pagefind --site ${outDir}`; - const pagefindCommand = process.platform === 'win32' ? windowsCommand : unixCommand; - - // Build pagefind into the output directory for the final build version - execSync( - `cross-env PROJECT_DIR='${dir}' CATALOG_DIR='${core}' ENABLE_EMBED=${canEmbedPages} EVENTCATALOG_STARTER=${isEventCatalogStarter} EVENTCATALOG_SCALE=${isEventCatalogScale} ${pagefindCommand}`, - { - cwd: dir, - stdio: 'inherit', - } - ); - - // Copy the pagefind directory into the public directory for dev mode - if (fs.existsSync(join(dir, outDir, 'pagefind'))) { - fs.cpSync(join(dir, outDir, 'pagefind'), join(dir, 'public', 'pagefind'), { recursive: true }); - } - } + // Turn off Pagefind for v3; todo; remove pagefind code once we know thats what we want. + // if (!isServerOutput) { + // const outDir = await getProjectOutDir(); + + // const windowsCommand = `npx -y pagefind --site ${outDir}`; + // const unixCommand = `npx -y pagefind --site ${outDir}`; + // const pagefindCommand = process.platform === 'win32' ? windowsCommand : unixCommand; + + // // Build pagefind into the output directory for the final build version + // execSync( + // `cross-env PROJECT_DIR='${dir}' CATALOG_DIR='${core}' ENABLE_EMBED=${canEmbedPages} EVENTCATALOG_STARTER=${isEventCatalogStarter} EVENTCATALOG_SCALE=${isEventCatalogScale} ${pagefindCommand}`, + // { + // cwd: dir, + // stdio: 'inherit', + // } + // ); + + // // Copy the pagefind directory into the public directory for dev mode + // if (fs.existsSync(join(dir, outDir, 'pagefind'))) { + // fs.cpSync(join(dir, outDir, 'pagefind'), join(dir, 'public', 'pagefind'), { recursive: true }); + // } + // } }); const previewCatalog = ({ From b1cfffc4d9daea8f79cf4c82dd6c1fd47e43f64f Mon Sep 17 00:00:00 2001 From: Rahil S Date: Tue, 9 Dec 2025 20:00:36 +0300 Subject: [PATCH 02/71] Fix 'Get Started' link in README (#1863) Updated the 'Get Started' link in the README. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ee9dd5531..88bde0212 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ You can read more on [how it works on GitHub](https://github.com/event-catalog/e You should be able to get setup within minutes if you head over to our documentation to get started 👇 -➡️ [Get Started](https://www.eventcatalog.dev/docs/development/getting-started/installation) +➡️ [Get Started](https://www.eventcatalog.dev/docs/development/getting-started) Or run this command to build a new catalog From 286c4769bc1fefcb1fae23aab6db7f3ca85ecd3e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 17:00:51 +0000 Subject: [PATCH 03/71] Version Packages (beta) (#1868) Co-authored-by: github-actions[bot] --- .changeset/pre.json | 4 +++- CHANGELOG.md | 6 ++++++ package.json | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.changeset/pre.json b/.changeset/pre.json index f13cb45ab..0abf4a89a 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -4,5 +4,7 @@ "initialVersions": { "@eventcatalog/core": "2.65.1" }, - "changesets": [] + "changesets": [ + "late-zoos-scream" + ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a2da8a41..711cba969 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # @eventcatalog/core +## 3.0.0-beta.0 + +### Major Changes + +- 1d1111d: feat(core): eventcatalog-v3 release + ## 2.65.1 ### Patch Changes diff --git a/package.json b/package.json index df2604925..8e5e9daa3 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "url": "https://github.com/event-catalog/eventcatalog.git" }, "type": "module", - "version": "2.65.1", + "version": "3.0.0-beta.0", "publishConfig": { "access": "public" }, From 8ca543683797c449c2b80e7a49c72fc5cd17df9a Mon Sep 17 00:00:00 2001 From: David Boyne Date: Tue, 9 Dec 2025 17:45:53 +0000 Subject: [PATCH 04/71] chore(core): fixing circular dep in JS (#1870) * chore(core): fixing circular dep in JS * Create selfish-geese-wave.md --- .changeset/pre.json | 4 +--- .changeset/selfish-geese-wave.md | 5 +++++ eventcatalog/src/utils/collections/domains.ts | 2 +- eventcatalog/src/utils/collections/types.ts | 6 ++++++ eventcatalog/tsconfig.json | 3 ++- scripts/build-ci.js | 1 + 6 files changed, 16 insertions(+), 5 deletions(-) create mode 100644 .changeset/selfish-geese-wave.md create mode 100644 eventcatalog/src/utils/collections/types.ts diff --git a/.changeset/pre.json b/.changeset/pre.json index 0abf4a89a..82c3a42a6 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -4,7 +4,5 @@ "initialVersions": { "@eventcatalog/core": "2.65.1" }, - "changesets": [ - "late-zoos-scream" - ] + "changesets": ["late-zoos-scream"] } diff --git a/.changeset/selfish-geese-wave.md b/.changeset/selfish-geese-wave.md new file mode 100644 index 000000000..091e0ac59 --- /dev/null +++ b/.changeset/selfish-geese-wave.md @@ -0,0 +1,5 @@ +--- +"@eventcatalog/core": patch +--- + +chore(core): fixing circular dep in JS diff --git a/eventcatalog/src/utils/collections/domains.ts b/eventcatalog/src/utils/collections/domains.ts index fe0c1c6ce..16ee27760 100644 --- a/eventcatalog/src/utils/collections/domains.ts +++ b/eventcatalog/src/utils/collections/domains.ts @@ -2,7 +2,7 @@ import { getCollection } from 'astro:content'; import type { CollectionEntry } from 'astro:content'; import path from 'path'; import type { CollectionMessageTypes } from '@types'; -import type { Service } from './services'; +import type { Service } from './types'; import utils from '@eventcatalog/sdk'; import { createVersionedMap, findInMap } from '@utils/collections/util'; diff --git a/eventcatalog/src/utils/collections/types.ts b/eventcatalog/src/utils/collections/types.ts new file mode 100644 index 000000000..eb98de163 --- /dev/null +++ b/eventcatalog/src/utils/collections/types.ts @@ -0,0 +1,6 @@ +import type { CollectionEntry } from 'astro:content'; + +// Shared types to avoid circular dependencies between domains.ts and services.ts +export type Service = CollectionEntry<'services'>; +export type Domain = CollectionEntry<'domains'>; +export type UbiquitousLanguage = CollectionEntry<'ubiquitousLanguages'>; diff --git a/eventcatalog/tsconfig.json b/eventcatalog/tsconfig.json index 343352f84..d82e2841c 100644 --- a/eventcatalog/tsconfig.json +++ b/eventcatalog/tsconfig.json @@ -21,5 +21,6 @@ "jsx": "react-jsx", "jsxImportSource": "react", "types": ["vitest/globals"] - } + }, + "exclude": ["**/*.spec.ts", "**/*.test.ts"] } diff --git a/scripts/build-ci.js b/scripts/build-ci.js index 1d3f75887..7de50691e 100644 --- a/scripts/build-ci.js +++ b/scripts/build-ci.js @@ -31,5 +31,6 @@ execSync(`pnpm exec astro check --minimumSeverity error --root ${catalogDir}`, { PATH: process.env.PATH, CATALOG_DIR: catalogDir, PROJECT_DIR: projectDIR, + NODE_OPTIONS: '--max-old-space-size=8192', }, }); From ddc8af5827e67a7627c7d34b30aba1b6dd2bdc08 Mon Sep 17 00:00:00 2001 From: David Boyne Date: Tue, 9 Dec 2025 19:58:42 +0000 Subject: [PATCH 05/71] fix(core): fixed issues with nested sidebar state (#1873) * fix(core): fixed issues with nested sidebar state * Create mighty-walls-watch.md --- .changeset/mighty-walls-watch.md | 5 + .../SideNav/NestedSideBar/index.tsx | 78 +++++++---- .../pages/docs/custom/index.astro | 2 +- .../eventcatalog-chat/pages/chat/index.astro | 2 +- .../src/layouts/DirectoryLayout.astro | 2 +- eventcatalog/src/layouts/DiscoverLayout.astro | 2 +- .../src/layouts/VerticalSideBarLayout.astro | 127 ++---------------- eventcatalog/src/pages/chat/feature.astro | 2 +- .../src/pages/docs/custom/feature.astro | 2 +- .../src/pages/docs/custom/index.astro | 2 +- eventcatalog/src/pages/plans/index.astro | 2 +- .../src/pages/schemas/explorer/index.astro | 2 +- eventcatalog/src/pages/studio.astro | 2 +- .../src/pages/unauthorized/index.astro | 2 +- eventcatalog/src/utils/feature.ts | 2 + 15 files changed, 79 insertions(+), 155 deletions(-) create mode 100644 .changeset/mighty-walls-watch.md diff --git a/.changeset/mighty-walls-watch.md b/.changeset/mighty-walls-watch.md new file mode 100644 index 000000000..3cf6fb67f --- /dev/null +++ b/.changeset/mighty-walls-watch.md @@ -0,0 +1,5 @@ +--- +"@eventcatalog/core": patch +--- + +fix(core): fixed issues with nested sidebar state diff --git a/eventcatalog/src/components/SideNav/NestedSideBar/index.tsx b/eventcatalog/src/components/SideNav/NestedSideBar/index.tsx index 1acf39734..9e68cc51d 100644 --- a/eventcatalog/src/components/SideNav/NestedSideBar/index.tsx +++ b/eventcatalog/src/components/SideNav/NestedSideBar/index.tsx @@ -42,13 +42,15 @@ type NavigationLevel = { export default function NestedSideBar() { const data = useStore(sidebarStore); + const favorites = useStore(favoritesStore); // Guard against undefined data (e.g., during hydration) - const roots = data?.roots ?? []; - const nodes = data?.nodes ?? {}; + // Use useMemo to ensure stable references for roots and nodes + const roots = useMemo(() => data?.roots ?? [], [data?.roots]); + const nodes = useMemo(() => data?.nodes ?? {}, [data?.nodes]); - const [navigationStack, setNavigationStack] = useState([ - { key: null, entries: roots, title: 'Documentation' }, + const [navigationStack, setNavigationStack] = useState(() => [ + { key: null, entries: [], title: 'Documentation' }, ]); const [animationKey, setAnimationKey] = useState(0); const [slideDirection, setSlideDirection] = useState<'forward' | 'backward' | null>(null); @@ -57,7 +59,6 @@ export default function NestedSideBar() { const [collapsedSections, setCollapsedSections] = useState>(new Set()); const [showPathPreview, setShowPathPreview] = useState(false); const [showFullPath, setShowFullPath] = useState(false); - const favorites = useStore(favoritesStore); const [isSearching, setIsSearching] = useState(false); // Build a lookup map for faster URL navigation @@ -113,6 +114,21 @@ export default function NestedSideBar() { } }, []); + /** + * Update navigation stack when roots become available + */ + useEffect(() => { + if (roots.length > 0) { + setNavigationStack((prevStack) => { + // Only update if the current stack has no entries (initial state) + if (prevStack.length === 1 && prevStack[0].entries.length === 0) { + return [{ key: null, entries: roots, title: 'Documentation' }]; + } + return prevStack; + }); + } + }, [roots]); + /** * Populate the store with the data when the component mounts or data changes */ @@ -300,43 +316,47 @@ export default function NestedSideBar() { const foundNodeKey = findNodeKeyByUrl(url); if (foundNodeKey) { - // Try to connect to current stack first - const connectedStack = tryConnectStack(foundNodeKey, navigationStack); + setNavigationStack((currentStack) => { + // Try to connect to current stack first + const connectedStack = tryConnectStack(foundNodeKey, currentStack); - if (connectedStack) { - setNavigationStack(connectedStack); - return true; - } + if (connectedStack) { + return connectedStack; + } - const foundNode = nodes[foundNodeKey]; - if (foundNode && foundNode.pages && foundNode.pages.length > 0) { - // Fallback: Flattened navigation - setNavigationStack([ - { key: null, entries: roots, title: 'Documentation' }, - { key: foundNodeKey, entries: foundNode.pages, title: foundNode.title, badge: foundNode.badge }, - ]); - return true; - } + const foundNode = nodes[foundNodeKey]; + if (foundNode && foundNode.pages && foundNode.pages.length > 0) { + // Fallback: Flattened navigation + return [ + { key: null, entries: roots, title: 'Documentation' }, + { key: foundNodeKey, entries: foundNode.pages, title: foundNode.title, badge: foundNode.badge }, + ]; + } + + return currentStack; + }); + return true; } else if (url === '/' || url === '') { // Reset to root if we are on homepage - if (navigationStack.length > 1) { - setSlideDirection('backward'); - setAnimationKey((prev) => prev + 1); - } - setNavigationStack([{ key: null, entries: roots, title: 'Documentation' }]); + setNavigationStack((currentStack) => { + if (currentStack.length > 1) { + setSlideDirection('backward'); + setAnimationKey((prev) => prev + 1); + } + return [{ key: null, entries: roots, title: 'Documentation' }]; + }); return true; } return false; }, - [findNodeKeyByUrl, tryConnectStack, navigationStack, nodes, roots] + [findNodeKeyByUrl, tryConnectStack, nodes, roots] ); /** * Restore state from localStorage on mount, or navigate to URL */ useEffect(() => { - if (!data || roots.length === 0) return; - if (isInitialized) return; + if (!data || roots.length === 0 || isInitialized) return; const currentUrl = window.location.pathname; @@ -387,7 +407,7 @@ export default function NestedSideBar() { } setIsInitialized(true); - }, [data, roots, buildStackFromPath, isInitialized, findNodeKeyByUrl, tryConnectStack, nodes]); + }, [data, roots, nodes, isInitialized, buildStackFromPath, findNodeKeyByUrl, tryConnectStack]); /** * Save state whenever navigation changes diff --git a/eventcatalog/src/enterprise/custom-documentation/pages/docs/custom/index.astro b/eventcatalog/src/enterprise/custom-documentation/pages/docs/custom/index.astro index 863d5c859..8316f85e2 100644 --- a/eventcatalog/src/enterprise/custom-documentation/pages/docs/custom/index.astro +++ b/eventcatalog/src/enterprise/custom-documentation/pages/docs/custom/index.astro @@ -80,7 +80,7 @@ const ownersList = filteredOwners.map((o) => ({ const badges = doc?.badges || []; --- - +
    @@ -396,7 +373,7 @@ const canPageBeEmbedded = process.env.ENABLE_EMBED === 'true'; - diff --git a/eventcatalog/src/pages/chat/feature.astro b/eventcatalog/src/pages/chat/feature.astro index cf8a0d167..8df7618df 100644 --- a/eventcatalog/src/pages/chat/feature.astro +++ b/eventcatalog/src/pages/chat/feature.astro @@ -19,7 +19,7 @@ if (hasChatLicense) { EventCatalog chat? - +
    {/* Hero Section */} diff --git a/eventcatalog/src/pages/docs/custom/feature.astro b/eventcatalog/src/pages/docs/custom/feature.astro index d9e18ac60..22c39142b 100644 --- a/eventcatalog/src/pages/docs/custom/feature.astro +++ b/eventcatalog/src/pages/docs/custom/feature.astro @@ -3,7 +3,7 @@ import VerticalSideBarLayout from '@layouts/VerticalSideBarLayout.astro'; import { BookOpenIcon, FileText } from 'lucide-react'; --- - +
    {/* Hero Section */} diff --git a/eventcatalog/src/pages/docs/custom/index.astro b/eventcatalog/src/pages/docs/custom/index.astro index 69453f315..418bddd88 100644 --- a/eventcatalog/src/pages/docs/custom/index.astro +++ b/eventcatalog/src/pages/docs/custom/index.astro @@ -98,7 +98,7 @@ if (!isCustomDocsEnabled()) { } --- - +
    diff --git a/eventcatalog/src/pages/plans/index.astro b/eventcatalog/src/pages/plans/index.astro index dc9bb7111..2268f590a 100644 --- a/eventcatalog/src/pages/plans/index.astro +++ b/eventcatalog/src/pages/plans/index.astro @@ -16,7 +16,7 @@ import { } from 'lucide-react'; --- - +
    {/* Hero Section */} diff --git a/eventcatalog/src/pages/schemas/explorer/index.astro b/eventcatalog/src/pages/schemas/explorer/index.astro index 492db7cd8..2b1099d2a 100644 --- a/eventcatalog/src/pages/schemas/explorer/index.astro +++ b/eventcatalog/src/pages/schemas/explorer/index.astro @@ -162,7 +162,7 @@ const allSchemas = [...messagesWithSchemas, ...flatServicesWithSpecs]; const apiAccessEnabled = isEventCatalogScaleEnabled(); --- - +
    diff --git a/eventcatalog/src/pages/studio.astro b/eventcatalog/src/pages/studio.astro index 410c2c295..49240d78d 100644 --- a/eventcatalog/src/pages/studio.astro +++ b/eventcatalog/src/pages/studio.astro @@ -44,7 +44,7 @@ const resourcesToShow = [ EventCatalog Studio - +
    {/* Hero Section */} diff --git a/eventcatalog/src/pages/unauthorized/index.astro b/eventcatalog/src/pages/unauthorized/index.astro index 360e15f7e..ac0df1f04 100644 --- a/eventcatalog/src/pages/unauthorized/index.astro +++ b/eventcatalog/src/pages/unauthorized/index.astro @@ -2,7 +2,7 @@ import VerticalSideBarLayout from '@layouts/VerticalSideBarLayout.astro'; --- - +
    diff --git a/eventcatalog/src/utils/feature.ts b/eventcatalog/src/utils/feature.ts index fa02e9550..bc4d40665 100644 --- a/eventcatalog/src/utils/feature.ts +++ b/eventcatalog/src/utils/feature.ts @@ -21,6 +21,8 @@ export const isEventCatalogScaleEnabled = () => process.env.EVENTCATALOG_SCALE = export const isPrivateRemoteSchemaEnabled = () => isEventCatalogScaleEnabled() || isEventCatalogStarterEnabled(); +export const isEmbedEnabled = () => process.env.ENABLE_EMBED === 'true'; + export const showEventCatalogBranding = () => { const override = process.env.EVENTCATALOG_SHOW_BRANDING; // if any value we return true From 47820c953e5255b2139cc6b2bcb8329d3a913871 Mon Sep 17 00:00:00 2001 From: David Boyne Date: Tue, 9 Dec 2025 20:03:40 +0000 Subject: [PATCH 06/71] fix(core): fixed issues with nested sidebar state --- .../src/layouts/VerticalSideBarLayout.astro | 45 +++++++++---------- 1 file changed, 21 insertions(+), 24 deletions(-) diff --git a/eventcatalog/src/layouts/VerticalSideBarLayout.astro b/eventcatalog/src/layouts/VerticalSideBarLayout.astro index 7cda3e394..796187e91 100644 --- a/eventcatalog/src/layouts/VerticalSideBarLayout.astro +++ b/eventcatalog/src/layouts/VerticalSideBarLayout.astro @@ -9,7 +9,6 @@ interface Props { import { TableProperties, House, - BookUser, BotMessageSquare, Users, Sparkles, @@ -246,29 +245,27 @@ const canPageBeEmbedded = isEmbedEnabled(); )} From 2fb0452ef76e07ba83e4325c525e9960b2fb9c86 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 11:07:00 +0000 Subject: [PATCH 19/71] Version Packages (beta) (#1885) Co-authored-by: github-actions[bot] --- .changeset/pre.json | 2 ++ CHANGELOG.md | 7 +++++++ package.json | 2 +- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.changeset/pre.json b/.changeset/pre.json index fa4454671..bcbdd0fab 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -6,9 +6,11 @@ }, "changesets": [ "clever-turkeys-wink", + "five-cougars-smell", "gold-dryers-sleep", "khaki-humans-wonder", "late-zoos-scream", + "light-humans-mate", "mighty-walls-watch", "selfish-geese-wave", "sweet-feet-reflect", diff --git a/CHANGELOG.md b/CHANGELOG.md index 132e9be99..0170e3727 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # @eventcatalog/core +## 3.0.0-beta.6 + +### Patch Changes + +- 713c535: chore(core): updated logger for the ecstudio watcher +- 2a32d7c: chore(core): added empty state to nested sidebar + ## 3.0.0-beta.5 ### Patch Changes diff --git a/package.json b/package.json index 8fed33d53..4c56baf5c 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "url": "https://github.com/event-catalog/eventcatalog.git" }, "type": "module", - "version": "3.0.0-beta.5", + "version": "3.0.0-beta.6", "publishConfig": { "access": "public" }, From cc6281394c0b2f42abae019bddf30adacb9975d1 Mon Sep 17 00:00:00 2001 From: David Boyne Date: Thu, 11 Dec 2025 13:40:16 +0000 Subject: [PATCH 20/71] fix(core): fixed issue embedding pages on build --- src/eventcatalog.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/eventcatalog.ts b/src/eventcatalog.ts index 7a8f95686..6fbade69f 100755 --- a/src/eventcatalog.ts +++ b/src/eventcatalog.ts @@ -279,13 +279,14 @@ program await copyServerFiles(); // Check if backstage is enabled - const canEmbedPages = await isFeatureEnabled( + const isBackstagePluginEnabled = await isFeatureEnabled( '@eventcatalog/backstage-plugin-eventcatalog', process.env.EVENTCATALOG_LICENSE_KEY_BACKSTAGE ); const isEventCatalogStarter = await isEventCatalogStarterEnabled(); const isEventCatalogScale = await isEventCatalogScaleEnabled(); - const isServerOutput = await isOutputServer(); + + const canEmbedPages = isBackstagePluginEnabled || isEventCatalogScale; // Create the auth.config.ts file if it doesn't exist await createAuthFileIfNotExists(isEventCatalogScale); From 341279e73f2c3978753104dcbbe20d622474185f Mon Sep 17 00:00:00 2001 From: David Boyne Date: Thu, 11 Dec 2025 13:47:49 +0000 Subject: [PATCH 21/71] fix(core): fixed issue embedding pages on build (#1887) * fix(core): fixed issue embedding pages on build * Create eleven-terms-agree.md --- .changeset/eleven-terms-agree.md | 5 +++++ .../workflows/redeploy-eventcatalog-examples.yml | 15 +++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 .changeset/eleven-terms-agree.md create mode 100644 .github/workflows/redeploy-eventcatalog-examples.yml diff --git a/.changeset/eleven-terms-agree.md b/.changeset/eleven-terms-agree.md new file mode 100644 index 000000000..32ffba582 --- /dev/null +++ b/.changeset/eleven-terms-agree.md @@ -0,0 +1,5 @@ +--- +"@eventcatalog/core": patch +--- + +fix(core): fixed issue embedding pages on build diff --git a/.github/workflows/redeploy-eventcatalog-examples.yml b/.github/workflows/redeploy-eventcatalog-examples.yml new file mode 100644 index 000000000..f435f0701 --- /dev/null +++ b/.github/workflows/redeploy-eventcatalog-examples.yml @@ -0,0 +1,15 @@ +name: Redeploy EventCatalog Examples on core release + +on: + push: + tags: + - core@* + +jobs: + redeploy: + runs-on: ubuntu-latest + steps: + - name: Redeploy EventCatalog Finance Example Catalog + run: curl -f -X POST "$VERCEL_DEPLOY_HOOK_URL" + env: + VERCEL_DEPLOY_HOOK_URL: ${{ secrets.VERCEL_DEPLOY_HOOK_FINANCE_EXAMPLE_CATALOG_URL }} \ No newline at end of file From f1d79e1520703130f2e1638a33e6473738f60e2a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 13:48:34 +0000 Subject: [PATCH 22/71] Version Packages (beta) (#1888) Co-authored-by: github-actions[bot] --- .changeset/pre.json | 1 + CHANGELOG.md | 6 ++++++ package.json | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.changeset/pre.json b/.changeset/pre.json index bcbdd0fab..286543b7b 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -6,6 +6,7 @@ }, "changesets": [ "clever-turkeys-wink", + "eleven-terms-agree", "five-cougars-smell", "gold-dryers-sleep", "khaki-humans-wonder", diff --git a/CHANGELOG.md b/CHANGELOG.md index 0170e3727..7ac053403 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # @eventcatalog/core +## 3.0.0-beta.7 + +### Patch Changes + +- 341279e: fix(core): fixed issue embedding pages on build + ## 3.0.0-beta.6 ### Patch Changes diff --git a/package.json b/package.json index 4c56baf5c..b63f8a9e5 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "url": "https://github.com/event-catalog/eventcatalog.git" }, "type": "module", - "version": "3.0.0-beta.6", + "version": "3.0.0-beta.7", "publishConfig": { "access": "public" }, From 507d14d621fc1ed5ced1dd057280ea15abb51147 Mon Sep 17 00:00:00 2001 From: David Boyne Date: Thu, 11 Dec 2025 13:52:15 +0000 Subject: [PATCH 23/71] fix(core): fixed deployment of example catalogs on releases (#1889) * fix(core): fixed issue embedding pages on build * fix(core): fixed issue embedding pages on build * Create early-bottles-reply.md --- .changeset/early-bottles-reply.md | 5 +++++ .github/workflows/redeploy-eventcatalog-examples.yml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/early-bottles-reply.md diff --git a/.changeset/early-bottles-reply.md b/.changeset/early-bottles-reply.md new file mode 100644 index 000000000..3399a737b --- /dev/null +++ b/.changeset/early-bottles-reply.md @@ -0,0 +1,5 @@ +--- +"@eventcatalog/core": patch +--- + +fix(core): fixed deployment of example catalogs on releases diff --git a/.github/workflows/redeploy-eventcatalog-examples.yml b/.github/workflows/redeploy-eventcatalog-examples.yml index f435f0701..65f7b5240 100644 --- a/.github/workflows/redeploy-eventcatalog-examples.yml +++ b/.github/workflows/redeploy-eventcatalog-examples.yml @@ -3,7 +3,7 @@ name: Redeploy EventCatalog Examples on core release on: push: tags: - - core@* + - 'v*' jobs: redeploy: From 35aa815f7fd2b317829f02ae5c1c522f113d64d9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 13:53:20 +0000 Subject: [PATCH 24/71] Version Packages (beta) (#1890) Co-authored-by: github-actions[bot] --- .changeset/pre.json | 1 + CHANGELOG.md | 6 ++++++ package.json | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.changeset/pre.json b/.changeset/pre.json index 286543b7b..94b9be858 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -6,6 +6,7 @@ }, "changesets": [ "clever-turkeys-wink", + "early-bottles-reply", "eleven-terms-agree", "five-cougars-smell", "gold-dryers-sleep", diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ac053403..92bf6c1e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # @eventcatalog/core +## 3.0.0-beta.8 + +### Patch Changes + +- 507d14d: fix(core): fixed deployment of example catalogs on releases + ## 3.0.0-beta.7 ### Patch Changes diff --git a/package.json b/package.json index b63f8a9e5..b4c817ad0 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "url": "https://github.com/event-catalog/eventcatalog.git" }, "type": "module", - "version": "3.0.0-beta.7", + "version": "3.0.0-beta.8", "publishConfig": { "access": "public" }, From 8db71c95e70aabf3dec2c231d095db8470d73ef7 Mon Sep 17 00:00:00 2001 From: David Boyne Date: Thu, 11 Dec 2025 14:09:13 +0000 Subject: [PATCH 25/71] fix(core): fixed issue embedding pages on build (#1891) * fix(core): fixed issue embedding pages on build * fix(core): fixed issue embedding pages on build * fix(core): fixed issue embedding pages on build * Create dirty-sheep-doubt.md --- .changeset/dirty-sheep-doubt.md | 5 +++++ .github/workflows/redeploy-eventcatalog-examples.yml | 2 ++ 2 files changed, 7 insertions(+) create mode 100644 .changeset/dirty-sheep-doubt.md diff --git a/.changeset/dirty-sheep-doubt.md b/.changeset/dirty-sheep-doubt.md new file mode 100644 index 000000000..32ffba582 --- /dev/null +++ b/.changeset/dirty-sheep-doubt.md @@ -0,0 +1,5 @@ +--- +"@eventcatalog/core": patch +--- + +fix(core): fixed issue embedding pages on build diff --git a/.github/workflows/redeploy-eventcatalog-examples.yml b/.github/workflows/redeploy-eventcatalog-examples.yml index 65f7b5240..41591cdc8 100644 --- a/.github/workflows/redeploy-eventcatalog-examples.yml +++ b/.github/workflows/redeploy-eventcatalog-examples.yml @@ -4,6 +4,8 @@ on: push: tags: - 'v*' + release: + types: [published] jobs: redeploy: From 33f5da5c7ebee5473878d3c645a3aa8bb6e85d5e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 14:10:32 +0000 Subject: [PATCH 26/71] Version Packages (beta) (#1892) Co-authored-by: github-actions[bot] --- .changeset/pre.json | 1 + CHANGELOG.md | 6 ++++++ package.json | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.changeset/pre.json b/.changeset/pre.json index 94b9be858..147fbd17b 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -6,6 +6,7 @@ }, "changesets": [ "clever-turkeys-wink", + "dirty-sheep-doubt", "early-bottles-reply", "eleven-terms-agree", "five-cougars-smell", diff --git a/CHANGELOG.md b/CHANGELOG.md index 92bf6c1e9..fac575c42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # @eventcatalog/core +## 3.0.0-beta.9 + +### Patch Changes + +- 8db71c9: fix(core): fixed issue embedding pages on build + ## 3.0.0-beta.8 ### Patch Changes diff --git a/package.json b/package.json index b4c817ad0..a59d66f55 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "url": "https://github.com/event-catalog/eventcatalog.git" }, "type": "module", - "version": "3.0.0-beta.8", + "version": "3.0.0-beta.9", "publishConfig": { "access": "public" }, From c5592f10d2a77d94a6f92036c9a6f1dd7219c11c Mon Sep 17 00:00:00 2001 From: David Boyne Date: Thu, 11 Dec 2025 14:17:08 +0000 Subject: [PATCH 27/71] fix(core): fixed issue embedding pages on build (#1893) * fix(core): fixed issue embedding pages on build * fix(core): fixed issue embedding pages on build * fix(core): fixed issue embedding pages on build * fix(core): fixed issue embedding pages on build * Create curvy-clocks-complain.md --- .changeset/curvy-clocks-complain.md | 5 +++++ .github/workflows/redeploy-eventcatalog-examples.yml | 11 ++++------- 2 files changed, 9 insertions(+), 7 deletions(-) create mode 100644 .changeset/curvy-clocks-complain.md diff --git a/.changeset/curvy-clocks-complain.md b/.changeset/curvy-clocks-complain.md new file mode 100644 index 000000000..32ffba582 --- /dev/null +++ b/.changeset/curvy-clocks-complain.md @@ -0,0 +1,5 @@ +--- +"@eventcatalog/core": patch +--- + +fix(core): fixed issue embedding pages on build diff --git a/.github/workflows/redeploy-eventcatalog-examples.yml b/.github/workflows/redeploy-eventcatalog-examples.yml index 41591cdc8..98f08c908 100644 --- a/.github/workflows/redeploy-eventcatalog-examples.yml +++ b/.github/workflows/redeploy-eventcatalog-examples.yml @@ -1,12 +1,9 @@ name: Redeploy EventCatalog Examples on core release - on: - push: - tags: - - 'v*' - release: - types: [published] - + workflow_run: + workflows: ["Release"] + types: + - completed jobs: redeploy: runs-on: ubuntu-latest From 13fe50f8faff22f7ce13fa69859c22f2935b1985 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 14:18:19 +0000 Subject: [PATCH 28/71] Version Packages (beta) (#1894) Co-authored-by: github-actions[bot] --- .changeset/pre.json | 1 + CHANGELOG.md | 6 ++++++ package.json | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.changeset/pre.json b/.changeset/pre.json index 147fbd17b..c6d22f1ff 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -6,6 +6,7 @@ }, "changesets": [ "clever-turkeys-wink", + "curvy-clocks-complain", "dirty-sheep-doubt", "early-bottles-reply", "eleven-terms-agree", diff --git a/CHANGELOG.md b/CHANGELOG.md index fac575c42..293187520 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # @eventcatalog/core +## 3.0.0-beta.10 + +### Patch Changes + +- c5592f1: fix(core): fixed issue embedding pages on build + ## 3.0.0-beta.9 ### Patch Changes diff --git a/package.json b/package.json index a59d66f55..d60747bce 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "url": "https://github.com/event-catalog/eventcatalog.git" }, "type": "module", - "version": "3.0.0-beta.9", + "version": "3.0.0-beta.10", "publishConfig": { "access": "public" }, From 39fbd2f27890635f66884fd821327f7bed372863 Mon Sep 17 00:00:00 2001 From: David Boyne Date: Thu, 11 Dec 2025 16:33:09 +0000 Subject: [PATCH 29/71] feat(core): added support for titles on admonitions (#1895) * feat(core): added support for titles on admonitions * Create cyan-readers-hear.md --- .changeset/cyan-readers-hear.md | 5 +++ eventcatalog/src/remark-plugins/directives.ts | 39 ++++++++++++++----- 2 files changed, 35 insertions(+), 9 deletions(-) create mode 100644 .changeset/cyan-readers-hear.md diff --git a/.changeset/cyan-readers-hear.md b/.changeset/cyan-readers-hear.md new file mode 100644 index 000000000..7b439c040 --- /dev/null +++ b/.changeset/cyan-readers-hear.md @@ -0,0 +1,5 @@ +--- +"@eventcatalog/core": patch +--- + +feat(core): added support for titles on admonitions diff --git a/eventcatalog/src/remark-plugins/directives.ts b/eventcatalog/src/remark-plugins/directives.ts index 848136eb8..3a3568b3b 100644 --- a/eventcatalog/src/remark-plugins/directives.ts +++ b/eventcatalog/src/remark-plugins/directives.ts @@ -30,7 +30,33 @@ export function remarkDirectives() { class: `rounded-lg p-4 my-4 ${blockTypes[node.name as keyof typeof blockTypes] || ''}`, }; - // Create header div that will contain icon and type + // Check if there's a custom title (label) provided via :::note[Custom Title] + // In remark-directive, the label is stored in node.children as a paragraph node + // with data.directiveLabel = true + let titleChildren; + let contentChildren; + + const firstChild = node.children && node.children.length > 0 ? node.children[0] : null; + const hasCustomTitle = firstChild && firstChild.data?.directiveLabel === true; + + if (hasCustomTitle && firstChild) { + // Custom title was provided in the label - it contains markdown parsed as inline content + titleChildren = firstChild.children || [ + { type: 'text', value: node.name.charAt(0).toUpperCase() + node.name.slice(1) }, + ]; + contentChildren = node.children.slice(1); + } else { + // No custom title, use default based on directive name + titleChildren = [ + { + type: 'text', + value: node.name.charAt(0).toUpperCase() + node.name.slice(1), + }, + ]; + contentChildren = node.children; + } + + // Create header div that will contain icon and title const headerNode = { type: 'element', data: { @@ -70,7 +96,7 @@ export function remarkDirectives() { }, ], }, - // Type label + // Title (with support for markdown) { type: 'element', data: { @@ -79,12 +105,7 @@ export function remarkDirectives() { class: '', }, }, - children: [ - { - type: 'text', - value: node.name.charAt(0).toUpperCase() + node.name.slice(1), - }, - ], + children: titleChildren, }, ], }; @@ -98,7 +119,7 @@ export function remarkDirectives() { class: 'prose prose-md w-full !max-w-none ', }, }, - children: node.children, + children: contentChildren, }; // Replace node's children with header and content From dac4dc5d5b266364d626af2253c553991c9d8e8e Mon Sep 17 00:00:00 2001 From: David Boyne Date: Thu, 11 Dec 2025 16:40:21 +0000 Subject: [PATCH 30/71] feat(core): updated homepage styles (#1897) * feat(core): updated homepage styles * Create tall-adults-end.md --- .changeset/tall-adults-end.md | 5 +++++ eventcatalog/src/components/Grids/DomainGrid.tsx | 2 -- .../src/components/Grids/MessageGrid.tsx | 16 ++++++++-------- .../SideNav/NestedSideBar/builders/domain.ts | 13 +++++++------ eventcatalog/src/pages/_index.astro | 12 +++++------- 5 files changed, 25 insertions(+), 23 deletions(-) create mode 100644 .changeset/tall-adults-end.md diff --git a/.changeset/tall-adults-end.md b/.changeset/tall-adults-end.md new file mode 100644 index 000000000..49b3d876d --- /dev/null +++ b/.changeset/tall-adults-end.md @@ -0,0 +1,5 @@ +--- +"@eventcatalog/core": patch +--- + +feat(core): updated homepage styles diff --git a/eventcatalog/src/components/Grids/DomainGrid.tsx b/eventcatalog/src/components/Grids/DomainGrid.tsx index 9f0e4ad4e..8c187429c 100644 --- a/eventcatalog/src/components/Grids/DomainGrid.tsx +++ b/eventcatalog/src/components/Grids/DomainGrid.tsx @@ -135,7 +135,6 @@ const ServiceCard = memo(({ service }: { service: any }) => { {/* Receives (Inbound) */}
    - Inbound Messages ({receives.length})
    @@ -162,7 +161,6 @@ const ServiceCard = memo(({ service }: { service: any }) => { {/* Sends (Outbound) */}
    - Outbound Messages ({sends.length})
    diff --git a/eventcatalog/src/components/Grids/MessageGrid.tsx b/eventcatalog/src/components/Grids/MessageGrid.tsx index b1036f89b..221891df0 100644 --- a/eventcatalog/src/components/Grids/MessageGrid.tsx +++ b/eventcatalog/src/components/Grids/MessageGrid.tsx @@ -41,9 +41,9 @@ export default function MessageGridV2({ service, embeded = false }: MessageGridV ); return ( -
    +
    {/* Service Title */} - */}
    {/* Left Column - Receives Messages & Reads From Containers */} @@ -70,7 +70,7 @@ export default function MessageGridV2({ service, embeded = false }: MessageGridV

    - Receives ({receives.length}) + Inbound Messages ({receives.length})

    {receives.length > 0 ? ( @@ -124,7 +124,7 @@ export default function MessageGridV2({ service, embeded = false }: MessageGridV
    {/* Service Information (Center) */} -
    +

    {service.data.name}

    @@ -133,11 +133,11 @@ export default function MessageGridV2({ service, embeded = false }: MessageGridV
    {receives.length}
    -
    Receives
    +
    Inbound Messages
    {sends.length}
    -
    Sends
    +
    Outbound Messages
    {readsFrom.length > 0 && (
    @@ -168,7 +168,7 @@ export default function MessageGridV2({ service, embeded = false }: MessageGridV

    - Sends ({sends.length}) + Outbound Messages ({sends.length})

    {sends.length > 0 ? ( diff --git a/eventcatalog/src/components/SideNav/NestedSideBar/builders/domain.ts b/eventcatalog/src/components/SideNav/NestedSideBar/builders/domain.ts index c8460be81..491c9ba2e 100644 --- a/eventcatalog/src/components/SideNav/NestedSideBar/builders/domain.ts +++ b/eventcatalog/src/components/SideNav/NestedSideBar/builders/domain.ts @@ -65,6 +65,12 @@ export const buildDomainNode = (domain: CollectionEntry<'domains'>, owners: any[ }, ].filter(Boolean) as ChildRef[], }, + renderSubDomains && { + type: 'group', + title: 'Subdomains', + icon: 'Boxes', + pages: subDomains.map((domain) => `domain:${(domain as any).data.id}:${(domain as any).data.version}`), + }, hasFlows && { type: 'group', title: 'Flows', @@ -81,12 +87,7 @@ export const buildDomainNode = (domain: CollectionEntry<'domains'>, owners: any[ href: buildUrl(`/docs/entities/${(entity as any).data.id}/${(entity as any).data.version}`), })), }, - renderSubDomains && { - type: 'group', - title: 'Subdomains', - icon: 'Boxes', - pages: subDomains.map((domain) => `domain:${(domain as any).data.id}:${(domain as any).data.version}`), - }, + ...(hasResourceGroups ? buildResourceGroupSections(resourceGroups, context) : []), renderServices && { type: 'group', diff --git a/eventcatalog/src/pages/_index.astro b/eventcatalog/src/pages/_index.astro index 2d845e4f5..8eb1a3c1c 100644 --- a/eventcatalog/src/pages/_index.astro +++ b/eventcatalog/src/pages/_index.astro @@ -174,7 +174,7 @@ const topTiles = [ description: 'Business domains defined', href: buildUrl('/discover/domains'), icon: RectangleGroupIcon, - bgColor: 'bg-yellow-100', + bgColor: 'bg-yellow-500', textColor: 'text-yellow-600', arrowColor: 'text-yellow-600', }, @@ -184,7 +184,7 @@ const topTiles = [ description: 'Services documented in the catalog', href: buildUrl('/discover/services'), icon: ServerIcon, - bgColor: 'bg-pink-100', + bgColor: 'bg-pink-500', textColor: 'text-pink-600', arrowColor: 'text-pink-600', }, @@ -194,7 +194,7 @@ const topTiles = [ description: 'Messages documented in the catalog', href: buildUrl('/discover/events'), icon: ChatBubbleLeftIcon, - bgColor: 'bg-blue-100', + bgColor: 'bg-blue-500', textColor: 'text-blue-600', arrowColor: 'text-blue-600', }, @@ -204,7 +204,7 @@ const topTiles = [ description: 'Business flows documented', href: buildUrl('/discover/flows'), icon: Workflow, - bgColor: 'bg-purple-100', + bgColor: 'bg-purple-500', textColor: 'text-purple-600', arrowColor: 'text-purple-600', }, @@ -347,9 +347,7 @@ const quickActions = [
    {/* Colored left border accent */} -
    +
    From d829b7b955b80a3524b16babe363bc6a2e777e55 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 16:55:38 +0000 Subject: [PATCH 31/71] Version Packages (beta) (#1898) Co-authored-by: github-actions[bot] --- .changeset/pre.json | 2 ++ CHANGELOG.md | 7 +++++++ package.json | 2 +- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.changeset/pre.json b/.changeset/pre.json index c6d22f1ff..d5aafd9ee 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -7,6 +7,7 @@ "changesets": [ "clever-turkeys-wink", "curvy-clocks-complain", + "cyan-readers-hear", "dirty-sheep-doubt", "early-bottles-reply", "eleven-terms-agree", @@ -18,6 +19,7 @@ "mighty-walls-watch", "selfish-geese-wave", "sweet-feet-reflect", + "tall-adults-end", "tiny-suns-lie" ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 293187520..26aba64e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # @eventcatalog/core +## 3.0.0-beta.11 + +### Patch Changes + +- 39fbd2f: feat(core): added support for titles on admonitions +- dac4dc5: feat(core): updated homepage styles + ## 3.0.0-beta.10 ### Patch Changes diff --git a/package.json b/package.json index d60747bce..6b8d92ebe 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "url": "https://github.com/event-catalog/eventcatalog.git" }, "type": "module", - "version": "3.0.0-beta.10", + "version": "3.0.0-beta.11", "publishConfig": { "access": "public" }, From 8ed19607030f56d499aaff4b838bea30f701f100 Mon Sep 17 00:00:00 2001 From: David Boyne Date: Thu, 11 Dec 2025 17:50:04 +0000 Subject: [PATCH 32/71] chore(core): removed unused icons on domain grid (#1899) * chore(core): removed unused icons on domain grid * Create good-humans-love.md --- .changeset/good-humans-love.md | 5 ++++ .../redeploy-eventcatalog-examples.yml | 27 +++++++++++++++++-- .../src/components/Grids/DomainGrid.tsx | 2 +- 3 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 .changeset/good-humans-love.md diff --git a/.changeset/good-humans-love.md b/.changeset/good-humans-love.md new file mode 100644 index 000000000..03df4fe74 --- /dev/null +++ b/.changeset/good-humans-love.md @@ -0,0 +1,5 @@ +--- +"@eventcatalog/core": patch +--- + +chore(core): removed unused icons on domain grid diff --git a/.github/workflows/redeploy-eventcatalog-examples.yml b/.github/workflows/redeploy-eventcatalog-examples.yml index 98f08c908..064ca3f15 100644 --- a/.github/workflows/redeploy-eventcatalog-examples.yml +++ b/.github/workflows/redeploy-eventcatalog-examples.yml @@ -5,10 +5,33 @@ on: types: - completed jobs: - redeploy: + redeploy-finance: runs-on: ubuntu-latest + if: > + github.event.workflow_run.conclusion == 'success' && + startsWith(github.event.workflow_run.head_commit.message, 'Version Packages') steps: - name: Redeploy EventCatalog Finance Example Catalog run: curl -f -X POST "$VERCEL_DEPLOY_HOOK_URL" env: - VERCEL_DEPLOY_HOOK_URL: ${{ secrets.VERCEL_DEPLOY_HOOK_FINANCE_EXAMPLE_CATALOG_URL }} \ No newline at end of file + VERCEL_DEPLOY_HOOK_URL: ${{ secrets.VERCEL_DEPLOY_HOOK_FINANCE_EXAMPLE_CATALOG_URL }} + redeploy-healthcare: + runs-on: ubuntu-latest + if: > + github.event.workflow_run.conclusion == 'success' && + startsWith(github.event.workflow_run.head_commit.message, 'Version Packages') + steps: + - name: Redeploy EventCatalog Healthcare Example Catalog + run: curl -f -X POST "$VERCEL_DEPLOY_HOOK_URL" + env: + VERCEL_DEPLOY_HOOK_URL: ${{ secrets.VERCEL_DEPLOY_HOOK_HEALTHCARE_EXAMPLE_CATALOG_URL }} + redeploy-demo: + runs-on: ubuntu-latest + if: > + github.event.workflow_run.conclusion == 'success' && + startsWith(github.event.workflow_run.head_commit.message, 'Version Packages') + steps: + - name: Redeploy EventCatalog Demo (FlowMart) Example Catalog + run: curl -f -X POST "$VERCEL_DEPLOY_HOOK_URL" + env: + VERCEL_DEPLOY_HOOK_URL: ${{ secrets.VERCEL_DEPLOY_HOOK_DEMO_FLOWMART_CATALOG_URL }} \ No newline at end of file diff --git a/eventcatalog/src/components/Grids/DomainGrid.tsx b/eventcatalog/src/components/Grids/DomainGrid.tsx index 8c187429c..fb126a82e 100644 --- a/eventcatalog/src/components/Grids/DomainGrid.tsx +++ b/eventcatalog/src/components/Grids/DomainGrid.tsx @@ -11,7 +11,7 @@ import { ArrowsPointingOutIcon, } from '@heroicons/react/24/outline'; import { buildUrl } from '@utils/url-builder'; -import { BoxIcon, ArrowRight, ArrowLeft } from 'lucide-react'; +import { BoxIcon } from 'lucide-react'; // ============================================ // Types From 35a7fae4397cb1e7838d7b3ca6c9a38e5fc24ede Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 17:50:58 +0000 Subject: [PATCH 33/71] Version Packages (beta) (#1900) Co-authored-by: github-actions[bot] --- .changeset/pre.json | 1 + CHANGELOG.md | 6 ++++++ package.json | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.changeset/pre.json b/.changeset/pre.json index d5aafd9ee..005e674f2 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -13,6 +13,7 @@ "eleven-terms-agree", "five-cougars-smell", "gold-dryers-sleep", + "good-humans-love", "khaki-humans-wonder", "late-zoos-scream", "light-humans-mate", diff --git a/CHANGELOG.md b/CHANGELOG.md index 26aba64e6..cf4c76042 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # @eventcatalog/core +## 3.0.0-beta.12 + +### Patch Changes + +- 8ed1960: chore(core): removed unused icons on domain grid + ## 3.0.0-beta.11 ### Patch Changes diff --git a/package.json b/package.json index 6b8d92ebe..1c6168f67 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "url": "https://github.com/event-catalog/eventcatalog.git" }, "type": "module", - "version": "3.0.0-beta.11", + "version": "3.0.0-beta.12", "publishConfig": { "access": "public" }, From b8730a9c0b012f7675e03300ca61a0294af85192 Mon Sep 17 00:00:00 2001 From: David Boyne Date: Thu, 11 Dec 2025 19:55:22 +0000 Subject: [PATCH 34/71] fix(core): mdx pages are added to teams and users (#1901) * fix(core): mdx pages are added to teams and users * Create sharp-files-occur.md --- .changeset/sharp-files-occur.md | 5 +++ eventcatalog/src/pages/docs/teams/[id].mdx.ts | 36 +++++++++++++++++++ eventcatalog/src/pages/docs/users/[id].mdx.ts | 36 +++++++++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 .changeset/sharp-files-occur.md create mode 100644 eventcatalog/src/pages/docs/teams/[id].mdx.ts create mode 100644 eventcatalog/src/pages/docs/users/[id].mdx.ts diff --git a/.changeset/sharp-files-occur.md b/.changeset/sharp-files-occur.md new file mode 100644 index 000000000..8f5960e18 --- /dev/null +++ b/.changeset/sharp-files-occur.md @@ -0,0 +1,5 @@ +--- +"@eventcatalog/core": patch +--- + +fix(core): mdx pages are added to teams and users diff --git a/eventcatalog/src/pages/docs/teams/[id].mdx.ts b/eventcatalog/src/pages/docs/teams/[id].mdx.ts new file mode 100644 index 000000000..638085237 --- /dev/null +++ b/eventcatalog/src/pages/docs/teams/[id].mdx.ts @@ -0,0 +1,36 @@ +// This file exposes the markdown for EventCatalog in the Url +// For example http://localhost:3000/docs/teams/full-stack loads the Page and http://localhost:3000/docs/teams/full-stack.mdx loads the markdown +// This is used for the LLMs to load the markdown for the given item (llms.txt); + +import type { APIRoute } from 'astro'; +import { getCollection } from 'astro:content'; +import config from '@config'; +import fs from 'fs'; + +const teams = await getCollection('teams'); + +export async function getStaticPaths() { + // Just return empty array if LLMs are not enabled + if (!config.llmsTxt?.enabled) { + return []; + } + + return teams.map((team) => ({ + params: { type: 'teams', id: team.data.id }, + props: { content: team }, + })); +} + +export const GET: APIRoute = async ({ params, props }) => { + // Just return empty array if LLMs are not enabled + if (!config.llmsTxt?.enabled) { + return new Response('llms.txt is not enabled for this Catalog.', { status: 404 }); + } + + if (props?.content?.filePath) { + const file = fs.readFileSync(props.content.filePath, 'utf8'); + return new Response(file, { status: 200 }); + } + + return new Response('Not found', { status: 404 }); +}; diff --git a/eventcatalog/src/pages/docs/users/[id].mdx.ts b/eventcatalog/src/pages/docs/users/[id].mdx.ts new file mode 100644 index 000000000..4b0edafbd --- /dev/null +++ b/eventcatalog/src/pages/docs/users/[id].mdx.ts @@ -0,0 +1,36 @@ +// This file exposes the markdown for EventCatalog in the Url +// For example http://localhost:3000/docs/users/dboyne loads the Page and http://localhost:3000/docs/users/dboyne.mdx loads the markdown +// This is used for the LLMs to load the markdown for the given item (llms.txt); + +import type { APIRoute } from 'astro'; +import { getCollection } from 'astro:content'; +import config from '@config'; +import fs from 'fs'; + +const users = await getCollection('users'); + +export async function getStaticPaths() { + // Just return empty array if LLMs are not enabled + if (!config.llmsTxt?.enabled) { + return []; + } + + return users.map((user) => ({ + params: { type: 'users', id: user.data.id }, + props: { content: user }, + })); +} + +export const GET: APIRoute = async ({ params, props }) => { + // Just return empty array if LLMs are not enabled + if (!config.llmsTxt?.enabled) { + return new Response('llms.txt is not enabled for this Catalog.', { status: 404 }); + } + + if (props?.content?.filePath) { + const file = fs.readFileSync(props.content?.filePath, 'utf8'); + return new Response(file, { status: 200 }); + } + + return new Response('Not found', { status: 404 }); +}; From 0bc73d3a7bbef94f409cdb791e4da33d146c381c Mon Sep 17 00:00:00 2001 From: David Boyne Date: Thu, 11 Dec 2025 20:02:15 +0000 Subject: [PATCH 35/71] chore(core): auth is now more explict opt in (#1902) * chore(core): auth is now more explict opt in * Create happy-gorillas-fold.md --- .changeset/happy-gorillas-fold.md | 5 +++++ eventcatalog/src/utils/feature.ts | 5 +++-- src/eventcatalog.config.ts | 5 +++++ 3 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 .changeset/happy-gorillas-fold.md diff --git a/.changeset/happy-gorillas-fold.md b/.changeset/happy-gorillas-fold.md new file mode 100644 index 000000000..a57a509ee --- /dev/null +++ b/.changeset/happy-gorillas-fold.md @@ -0,0 +1,5 @@ +--- +"@eventcatalog/core": patch +--- + +chore(core): auth is now more explict opt in diff --git a/eventcatalog/src/utils/feature.ts b/eventcatalog/src/utils/feature.ts index bc4d40665..694a1ade0 100644 --- a/eventcatalog/src/utils/feature.ts +++ b/eventcatalog/src/utils/feature.ts @@ -51,9 +51,10 @@ export const isMarkdownDownloadEnabled = () => config?.llmsTxt?.enabled ?? false export const isLLMSTxtEnabled = () => (config?.llmsTxt?.enabled || isEventCatalogChatEnabled()) ?? false; export const isAuthEnabled = () => { + const isAuthEnabledInCatalog = config?.auth?.enabled ?? false; const directory = process.env.PROJECT_DIR || process.cwd(); - const hasAuthConfig = fs.existsSync(join(directory, 'eventcatalog.auth.js')); - return (hasAuthConfig && isSSR() && isEventCatalogScaleEnabled()) || false; + const hasAuthConfigurationFile = fs.existsSync(join(directory, 'eventcatalog.auth.js')); + return (isAuthEnabledInCatalog && hasAuthConfigurationFile && isSSR() && isEventCatalogScaleEnabled()) || false; }; export const isSSR = () => config?.output === 'server'; diff --git a/src/eventcatalog.config.ts b/src/eventcatalog.config.ts index 2ae537756..2861abf68 100644 --- a/src/eventcatalog.config.ts +++ b/src/eventcatalog.config.ts @@ -51,6 +51,10 @@ type PagesConfiguration = { pages?: string[]; }; +type AuthConfig = { + enabled: boolean; +}; + export interface Config { title: string; tagline: false; @@ -64,6 +68,7 @@ export interface Config { host?: string; trailingSlash?: boolean; output?: 'server' | 'static'; + auth?: AuthConfig; rss?: { enabled: boolean; limit: number; From 6c91497d931e527a9a63ef109db9d3cd00c2d594 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 20:08:21 +0000 Subject: [PATCH 36/71] Version Packages (beta) (#1903) Co-authored-by: github-actions[bot] --- .changeset/pre.json | 2 ++ CHANGELOG.md | 7 +++++++ package.json | 2 +- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.changeset/pre.json b/.changeset/pre.json index 005e674f2..e563e336e 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -14,11 +14,13 @@ "five-cougars-smell", "gold-dryers-sleep", "good-humans-love", + "happy-gorillas-fold", "khaki-humans-wonder", "late-zoos-scream", "light-humans-mate", "mighty-walls-watch", "selfish-geese-wave", + "sharp-files-occur", "sweet-feet-reflect", "tall-adults-end", "tiny-suns-lie" diff --git a/CHANGELOG.md b/CHANGELOG.md index cf4c76042..6c51aaa20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # @eventcatalog/core +## 3.0.0-beta.13 + +### Patch Changes + +- 0bc73d3: chore(core): auth is now more explict opt in +- b8730a9: fix(core): mdx pages are added to teams and users + ## 3.0.0-beta.12 ### Patch Changes diff --git a/package.json b/package.json index 1c6168f67..8087b016a 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "url": "https://github.com/event-catalog/eventcatalog.git" }, "type": "module", - "version": "3.0.0-beta.12", + "version": "3.0.0-beta.13", "publishConfig": { "access": "public" }, From 57d149638977d89dd9cbf828752e6f3e673b12eb Mon Sep 17 00:00:00 2001 From: David Boyne Date: Thu, 11 Dec 2025 21:17:15 +0000 Subject: [PATCH 37/71] chore(core): auth is now more explict opt in (#1904) * chore(core): auth is now more explict opt in * fix(core): fixed schema explorer in SSR mode * Create shaggy-adults-promise.md --- .changeset/shaggy-adults-promise.md | 5 + .../src/pages/schemas/explorer/_index.data.ts | 178 ++++++++++++++++++ .../src/pages/schemas/explorer/index.astro | 160 +--------------- eventcatalog/src/utils/resource-files.ts | 86 +++++++++ package.json | 1 + scripts/start-server-locally.js | 28 +++ src/eventcatalog.ts | 3 + 7 files changed, 306 insertions(+), 155 deletions(-) create mode 100644 .changeset/shaggy-adults-promise.md create mode 100644 eventcatalog/src/pages/schemas/explorer/_index.data.ts create mode 100644 eventcatalog/src/utils/resource-files.ts create mode 100644 scripts/start-server-locally.js diff --git a/.changeset/shaggy-adults-promise.md b/.changeset/shaggy-adults-promise.md new file mode 100644 index 000000000..a57a509ee --- /dev/null +++ b/.changeset/shaggy-adults-promise.md @@ -0,0 +1,5 @@ +--- +"@eventcatalog/core": patch +--- + +chore(core): auth is now more explict opt in diff --git a/eventcatalog/src/pages/schemas/explorer/_index.data.ts b/eventcatalog/src/pages/schemas/explorer/_index.data.ts new file mode 100644 index 000000000..0387bc55d --- /dev/null +++ b/eventcatalog/src/pages/schemas/explorer/_index.data.ts @@ -0,0 +1,178 @@ +import { isSSR } from '@utils/feature'; +import { HybridPage } from '@utils/page-loaders/hybrid-page'; +import { getEvents } from '@utils/collections/events'; +import { getCommands } from '@utils/collections/commands'; +import { getQueries } from '@utils/collections/queries'; +import { getServices, getSpecificationsForService } from '@utils/collections/services'; +import { getOwner } from '@utils/collections/owners'; +import { buildUrl } from '@utils/url-builder'; +import { resourceFileExists, readResourceFile } from '@utils/resource-files'; +import path from 'path'; + +// Helper function to enrich owners with full details +async function enrichOwners(ownersRaw: any[]) { + if (!ownersRaw || ownersRaw.length === 0) return []; + + const owners = await Promise.all(ownersRaw.map(getOwner)); + const filteredOwners = owners.filter((o) => o !== undefined); + + return filteredOwners.map((o) => ({ + id: o.data.id, + name: o.data.name, + type: o.collection, + href: buildUrl(`/docs/${o.collection}/${o.data.id}`), + })); +} + +async function fetchAllSchemas() { + // Fetch all messages + const events = await getEvents({ getAllVersions: true }); + const commands = await getCommands({ getAllVersions: true }); + const queries = await getQueries({ getAllVersions: true }); + + // Fetch all services + const services = await getServices({ getAllVersions: true }); + + // Combine all messages + const allMessages = [...events, ...commands, ...queries]; + + // Filter messages with schemas and read schema content - only keep essential data + const messagesWithSchemas = await Promise.all( + allMessages + .filter((message) => message.data.schemaPath) + .filter((message) => resourceFileExists(message, message.data.schemaPath ?? '')) + .map(async (message) => { + try { + const schemaPath = message.data.schemaPath ?? ''; + const schemaContent = readResourceFile(message, schemaPath) ?? ''; + const schemaExtension = path.extname(schemaPath).slice(1); + const enrichedOwners = await enrichOwners(message.data.owners || []); + + return { + collection: message.collection, + data: { + id: message.data.id, + name: message.data.name, + version: message.data.version, + summary: message.data.summary, + schemaPath: message.data.schemaPath, + producers: message.data.producers || [], + consumers: message.data.consumers || [], + owners: enrichedOwners, + }, + schemaContent, + schemaExtension, + }; + } catch (error) { + console.error(`Error reading schema for ${message.data.id}:`, error); + const enrichedOwners = await enrichOwners(message.data.owners || []); + return { + collection: message.collection, + data: { + id: message.data.id, + name: message.data.name, + version: message.data.version, + summary: message.data.summary, + schemaPath: message.data.schemaPath, + producers: message.data.producers || [], + consumers: message.data.consumers || [], + owners: enrichedOwners, + }, + schemaContent: '', + schemaExtension: 'json', + }; + } + }) + ); + + // Filter services with specifications and read spec content - only keep essential data + const servicesWithSpecs = await Promise.all( + services.map(async (service) => { + try { + const specifications = getSpecificationsForService(service); + + if (specifications.length === 0) { + return null; + } + + return await Promise.all( + specifications.map(async (spec) => { + if (!resourceFileExists(service, spec.path)) { + return null; + } + + const schemaContent = readResourceFile(service, spec.path) ?? ''; + const schemaExtension = spec.type; + const enrichedOwners = await enrichOwners(service.data.owners || []); + + return { + collection: 'services', + data: { + id: `${service.data.id}`, + name: `${service.data.name} - ${spec.name}`, + version: service.data.version, + summary: service.data.summary, + schemaPath: spec.path, + owners: enrichedOwners, + }, + schemaContent, + schemaExtension, + specType: spec.type, + specName: spec.name, + specFilenameWithoutExtension: spec.filenameWithoutExtension, + }; + }) + ); + } catch (error) { + console.error(`Error reading specifications for service ${service.data.id}:`, error); + return null; + } + }) + ); + + // Flatten and filter out null values + const flatServicesWithSpecs = servicesWithSpecs.flat().filter((service) => service !== null); + + return [...messagesWithSchemas, ...flatServicesWithSpecs]; +} + +export class Page extends HybridPage { + static get prerender(): boolean { + return !isSSR(); + } + + static async getStaticPaths(): Promise> { + if (isSSR()) { + return []; + } + + const allSchemas = await fetchAllSchemas(); + + return [ + { + params: {}, + props: { + schemas: allSchemas, + }, + }, + ]; + } + + protected static async fetchData(_params: any) { + const allSchemas = await fetchAllSchemas(); + return { + schemas: allSchemas, + }; + } + + protected static hasValidProps(props: any): boolean { + return props && props.schemas !== undefined; + } + + protected static createNotFoundResponse(): Response { + return new Response(null, { + status: 404, + statusText: 'Schema explorer not found', + }); + } +} diff --git a/eventcatalog/src/pages/schemas/explorer/index.astro b/eventcatalog/src/pages/schemas/explorer/index.astro index 2b1099d2a..701b87043 100644 --- a/eventcatalog/src/pages/schemas/explorer/index.astro +++ b/eventcatalog/src/pages/schemas/explorer/index.astro @@ -1,163 +1,13 @@ --- import VerticalSideBarLayout from '@layouts/VerticalSideBarLayout.astro'; -import { getEvents } from '@utils/collections/events'; -import { getCommands } from '@utils/collections/commands'; -import { getQueries } from '@utils/collections/queries'; -import { getServices, getSpecificationsForService } from '@utils/collections/services'; import SchemaExplorer from '@components/SchemaExplorer/SchemaExplorer'; import { isEventCatalogScaleEnabled } from '@utils/feature'; -import { getOwner } from '@utils/collections/owners'; -import { buildUrl } from '@utils/url-builder'; -import fs from 'fs'; -import path from 'path'; +import { Page } from './_index.data'; -// Fetch all messages -const events = await getEvents({ getAllVersions: true }); -const commands = await getCommands({ getAllVersions: true }); -const queries = await getQueries({ getAllVersions: true }); +export const prerender = Page.prerender; +export const getStaticPaths = Page.getStaticPaths; -// Fetch all services -const services = await getServices({ getAllVersions: true }); - -// Combine all messages -const allMessages = [...events, ...commands, ...queries]; - -// Helper function to enrich owners with full details -async function enrichOwners(ownersRaw: any[]) { - if (!ownersRaw || ownersRaw.length === 0) return []; - - const owners = await Promise.all(ownersRaw.map(getOwner)); - const filteredOwners = owners.filter((o) => o !== undefined); - - return filteredOwners.map((o) => ({ - id: o.data.id, - name: o.data.name, - type: o.collection, - href: buildUrl(`/docs/${o.collection}/${o.data.id}`), - })); -} - -// Filter messages with schemas and read schema content - only keep essential data -const messagesWithSchemas = await Promise.all( - allMessages - .filter((message) => message.data.schemaPath) - // Make sure the file exists - .filter((message) => fs.existsSync(path.join(path.dirname(message.filePath ?? ''), message.data.schemaPath ?? ''))) - .map(async (message) => { - try { - // Get the schema file path - const schemaPath = message.data.schemaPath; - const fullSchemaPath = path.join(path.dirname(message.filePath ?? ''), schemaPath ?? ''); - - // Read the schema content - let schemaContent = ''; - if (fs.existsSync(fullSchemaPath)) { - schemaContent = fs.readFileSync(fullSchemaPath, 'utf-8'); - } - - // Get schema file extension - const schemaExtension = path.extname(schemaPath ?? '').slice(1); - - // Enrich owners with full details - const enrichedOwners = await enrichOwners(message.data.owners || []); - - // Only return essential data - strip out markdown, full data objects, etc. - return { - collection: message.collection, - data: { - id: message.data.id, - name: message.data.name, - version: message.data.version, - summary: message.data.summary, - schemaPath: message.data.schemaPath, - producers: message.data.producers || [], - consumers: message.data.consumers || [], - owners: enrichedOwners, - }, - schemaContent, - schemaExtension, - }; - } catch (error) { - console.error(`Error reading schema for ${message.data.id}:`, error); - const enrichedOwners = await enrichOwners(message.data.owners || []); - return { - collection: message.collection, - data: { - id: message.data.id, - name: message.data.name, - version: message.data.version, - summary: message.data.summary, - schemaPath: message.data.schemaPath, - producers: message.data.producers || [], - consumers: message.data.consumers || [], - owners: enrichedOwners, - }, - schemaContent: '', - schemaExtension: 'json', - }; - } - }) -); - -// Filter services with specifications and read spec content - only keep essential data -const servicesWithSpecs = await Promise.all( - services.map(async (service) => { - try { - const specifications = getSpecificationsForService(service); - - // Only include services that have specifications - if (specifications.length === 0) { - return null; - } - - // Process each specification file for this service - return await Promise.all( - specifications.map(async (spec) => { - const specPath = path.join(path.dirname(service.filePath ?? ''), spec.path); - - // Only include if the spec file exists - if (!fs.existsSync(specPath)) { - return null; - } - - const schemaContent = fs.readFileSync(specPath, 'utf-8'); - // Use spec type (openapi, asyncapi) as the extension for proper labeling - const schemaExtension = spec.type; - - // Enrich owners with full details - const enrichedOwners = await enrichOwners(service.data.owners || []); - - // Only return essential data - strip out markdown, sends/receives, entities, etc. - return { - collection: 'services', - data: { - id: `${service.data.id}`, - name: `${service.data.name} - ${spec.name}`, - version: service.data.version, - summary: service.data.summary, - schemaPath: spec.path, - owners: enrichedOwners, - }, - schemaContent, - schemaExtension, - specType: spec.type, - specName: spec.name, - specFilenameWithoutExtension: spec.filenameWithoutExtension, - }; - }) - ); - } catch (error) { - console.error(`Error reading specifications for service ${service.data.id}:`, error); - return null; - } - }) -); - -// Flatten and filter out null values -const flatServicesWithSpecs = servicesWithSpecs.flat().filter((service) => service !== null); - -// Combine messages and services -const allSchemas = [...messagesWithSchemas, ...flatServicesWithSpecs]; +const { schemas } = await Page.getData(Astro); const apiAccessEnabled = isEventCatalogScaleEnabled(); --- @@ -167,7 +17,7 @@ const apiAccessEnabled = isEventCatalogScaleEnabled();
    - +
    diff --git a/eventcatalog/src/utils/resource-files.ts b/eventcatalog/src/utils/resource-files.ts new file mode 100644 index 000000000..2f29bc6dd --- /dev/null +++ b/eventcatalog/src/utils/resource-files.ts @@ -0,0 +1,86 @@ +import fs from 'fs'; +import path from 'path'; +import { isSSR } from '@utils/feature'; + +/** + * Get the absolute base path for a resource item. + * + * In SSR mode, filePath is relative to the Astro core directory (e.g., "../examples/default/domains/..."). + * We need to resolve it using PROJECT_DIR to get the correct absolute path. + * + * In static mode, filePath is resolved correctly by Astro's build context. + * + * @param item - The resource item with a filePath property + * @returns The absolute path to the directory containing the resource + */ +export function getResourceBasePath(item: { filePath?: string }): string { + if (!item.filePath) { + return ''; + } + + const filePath = item.filePath; + + // In SSR mode, we need to resolve the relative path using PROJECT_DIR + if (isSSR()) { + const PROJECT_DIR = process.env.PROJECT_DIR || ''; + + if (PROJECT_DIR) { + // Get the project folder name from PROJECT_DIR (e.g., "default" from ".../examples/default") + const projectFolderName = path.basename(PROJECT_DIR); + + // Find the project folder in the relative path and extract everything after it + // Pattern: ../examples/default/domains/... -> domains/... + const regex = new RegExp(`.*?${projectFolderName}/(.+)$`); + const match = filePath.match(regex); + + if (match && match[1]) { + // Join PROJECT_DIR with the relative path within the project + return path.join(PROJECT_DIR, path.dirname(match[1])); + } + } + } + + // Static mode: resolve directly using Astro's build context + return path.dirname(path.resolve(filePath)); +} + +/** + * Get the absolute path to a file within a resource directory. + * + * @param item - The resource item with a filePath property + * @param relativePath - The relative path to the file (e.g., "schema.json") + * @returns The absolute path to the file + */ +export function getResourceFilePath(item: { filePath?: string }, relativePath: string): string { + const basePath = getResourceBasePath(item); + return path.join(basePath, relativePath); +} + +/** + * Check if a file exists within a resource directory. + * + * @param item - The resource item with a filePath property + * @param relativePath - The relative path to the file (e.g., "schema.json") + * @returns True if the file exists, false otherwise + */ +export function resourceFileExists(item: { filePath?: string }, relativePath: string): boolean { + const filePath = getResourceFilePath(item, relativePath); + return fs.existsSync(filePath); +} + +/** + * Read a file from a resource directory. + * + * @param item - The resource item with a filePath property + * @param relativePath - The relative path to the file (e.g., "schema.json") + * @returns The file content as a string, or null if the file doesn't exist + */ +export function readResourceFile(item: { filePath?: string }, relativePath: string): string | null { + const filePath = getResourceFilePath(item, relativePath); + + if (!fs.existsSync(filePath)) { + return null; + } + + return fs.readFileSync(filePath, 'utf-8'); +} diff --git a/package.json b/package.json index 8087b016a..dd10d83cd 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "preview": "astro preview", "astro": "astro", "start:catalog": "node scripts/start-catalog-locally.js", + "start:catalog:server": "node scripts/start-server-locally.js", "pagefind": "node scripts/pagefind.js", "preview:catalog": "node scripts/preview-catalog-locally.js", "generate:catalog": "node scripts/generate-catalog-locally.js", diff --git a/scripts/start-server-locally.js b/scripts/start-server-locally.js new file mode 100644 index 000000000..feab7f548 --- /dev/null +++ b/scripts/start-server-locally.js @@ -0,0 +1,28 @@ +#!/usr/bin/env node +import { join } from 'node:path'; +import { execSync } from 'node:child_process'; + +async function main() { + const __dirname = import.meta.dirname; + + const args = process.argv.slice(2); + const catalog = args[0] || 'default'; + + const catalogDir = join(__dirname, '../eventcatalog/'); + const projectDIR = join(__dirname, `../examples/${catalog}`); + + // execSync('pnpm run build:bin', { stdio: 'inherit' }); + + execSync('pnpm run verify-build:catalog', { stdio: 'inherit' }); + + execSync(`cross-env NODE_ENV=development PROJECT_DIR=${projectDIR} CATALOG_DIR=${catalogDir} npx . start`, { + stdio: 'inherit', + }); +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/src/eventcatalog.ts b/src/eventcatalog.ts index 6fbade69f..a1ac46142 100755 --- a/src/eventcatalog.ts +++ b/src/eventcatalog.ts @@ -267,6 +267,7 @@ program logger.info('Building EventCatalog...', 'build'); const isServer = await isOutputServer(); + logger.info(isServer ? 'EventCatalog is running in Server Mode' : 'EventCatalog is running in Static Mode', 'config'); // Load any .env file in the project directory @@ -434,6 +435,8 @@ program const isEventCatalogStarter = await isEventCatalogStarterEnabled(); const isEventCatalogScale = await isEventCatalogScaleEnabled(); + await copyServerFiles(); + const isServerOutput = await isOutputServer(); if (isServerOutput) { From c270a9819182274047c890c2ccf407a4ecc7e547 Mon Sep 17 00:00:00 2001 From: David Boyne Date: Thu, 11 Dec 2025 21:33:43 +0000 Subject: [PATCH 38/71] fix(core): problems with asyncapi loading in the DOM (#1906) * fix(core): problems with asyncapi loading in the DOM * Create warm-scissors-help.md --- .changeset/warm-scissors-help.md | 5 +++ eventcatalog/src/components/Header.astro | 20 ++++++++- .../src/layouts/VerticalSideBarLayout.astro | 41 ++++++++++++++----- eventcatalog/src/pages/_index.astro | 1 + .../[id]/[version]/asyncapi/[filename].astro | 18 ++++++-- 5 files changed, 69 insertions(+), 16 deletions(-) create mode 100644 .changeset/warm-scissors-help.md diff --git a/.changeset/warm-scissors-help.md b/.changeset/warm-scissors-help.md new file mode 100644 index 000000000..7bc558918 --- /dev/null +++ b/.changeset/warm-scissors-help.md @@ -0,0 +1,5 @@ +--- +"@eventcatalog/core": patch +--- + +fix(core): problems with asyncapi loading in the DOM diff --git a/eventcatalog/src/components/Header.astro b/eventcatalog/src/components/Header.astro index 1451eb690..509cbd5ce 100644 --- a/eventcatalog/src/components/Header.astro +++ b/eventcatalog/src/components/Header.astro @@ -60,6 +60,7 @@ const repositoryUrl = catalog?.repositoryUrl || 'https://github.com/event-catalo class="flex items-center focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-400 rounded-full" aria-expanded="false" aria-haspopup="true" + aria-label="User menu" > {session.user?.image && !session.user?.image?.includes('googleusercontent.com') ? ( - +
  • - +
  • @@ -137,10 +150,13 @@ const repositoryUrl = catalog?.repositoryUrl || 'https://github.com/event-catalo diff --git a/eventcatalog/src/layouts/VerticalSideBarLayout.astro b/eventcatalog/src/layouts/VerticalSideBarLayout.astro index 2fce2cd1a..2c81439d7 100644 --- a/eventcatalog/src/layouts/VerticalSideBarLayout.astro +++ b/eventcatalog/src/layouts/VerticalSideBarLayout.astro @@ -248,15 +248,19 @@ const canPageBeEmbedded = isEmbedEnabled(); id={item.id} data-role="nav-item" href={item.href} + aria-label={item.label} class={`p-1.5 inline-block transition-colors duration-200 rounded-lg ${ item.current ? 'text-white bg-gray-900' : 'hover:bg-gray-800 hover:text-white text-gray-700' }`} >
    - + - +
    ); @@ -271,13 +275,17 @@ const canPageBeEmbedded = isEmbedEnabled(); id={studioNavigationItem[0].id} data-role="nav-item" href={studioNavigationItem[0].href} + aria-label={studioNavigationItem[0].label} class={`p-1.5 inline-block pt-1 pb-1 mt-0 mb-0 transition-colors duration-200 rounded-lg relative ${studioNavigationItem[0].current ? 'text-white bg-gray-900' : 'hover:bg-gray-800 hover:text-white text-gray-700'}`} >
    - + - +
    ) @@ -291,17 +299,24 @@ const canPageBeEmbedded = isEmbedEnabled(); id={item.id} data-role="nav-item" href={item.href} + aria-label={item.label} class={`p-1.5 inline-block transition-colors duration-200 rounded-lg mb-8 relative ${ item.current ? 'text-white bg-gray-900' : 'hover:bg-gray-800 hover:text-white text-gray-700' }`} >
    - - {item.label} + - -
    - +
    @@ -316,13 +331,17 @@ const canPageBeEmbedded = isEmbedEnabled(); id="/pro" data-role="nav-item" href={buildUrl('/plans')} + aria-label="Upgrade EventCatalog" class={`p-1.5 inline-block transition-colors duration-200 rounded-lg ${currentPath.includes('/pro') ? 'text-white bg-gray-900' : 'bg-gray-200 hover:bg-gray-800 hover:text-white text-gray-700'}`} >
    - + - +
    diff --git a/eventcatalog/src/pages/_index.astro b/eventcatalog/src/pages/_index.astro index 8eb1a3c1c..2b7c64546 100644 --- a/eventcatalog/src/pages/_index.astro +++ b/eventcatalog/src/pages/_index.astro @@ -304,6 +304,7 @@ const quickActions = [
    @@ -193,8 +194,10 @@ export default function SearchBar({ nodes, onSelectResult, onSearchChange }: Pro ? 'bg-purple-50 border-purple-200 text-purple-600' : 'bg-gray-50 border-gray-200 text-gray-400 hover:text-gray-600 hover:bg-gray-100' )} + aria-label="Filter search results" + aria-expanded={showFilterDropdown} > - +
    diff --git a/eventcatalog/src/pages/docs/[type]/[id]/[version]/index.astro b/eventcatalog/src/pages/docs/[type]/[id]/[version]/index.astro index af87df79f..9861bd49c 100644 --- a/eventcatalog/src/pages/docs/[type]/[id]/[version]/index.astro +++ b/eventcatalog/src/pages/docs/[type]/[id]/[version]/index.astro @@ -491,7 +491,10 @@ nodeGraphs.push({ }
    -