@eventcatalog/core 5.7.1 → 5.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/analytics/analytics.cjs +1 -1
  2. package/dist/analytics/analytics.js +2 -2
  3. package/dist/analytics/log-build.cjs +1 -1
  4. package/dist/analytics/log-build.js +3 -3
  5. package/dist/{chunk-GRC67WHC.js → chunk-GOIDVIMH.js} +1 -1
  6. package/dist/{chunk-HZ3XZKUT.js → chunk-L2PFSX4L.js} +1 -1
  7. package/dist/{chunk-36XVRD36.js → chunk-PLTHZFTW.js} +1 -1
  8. package/dist/{chunk-CPF5WE3R.js → chunk-PXYVJK54.js} +1 -1
  9. package/dist/{chunk-AXWTR2FV.js → chunk-RUG7AIT5.js} +1 -1
  10. package/dist/constants.cjs +1 -1
  11. package/dist/constants.js +1 -1
  12. package/dist/docs/development/ask-your-architecture/02-eventcatalog-assistant/01-what-is-eventcatalog-assistant.md +8 -0
  13. package/dist/docs/development/ask-your-architecture/02-eventcatalog-assistant/02-configuration.md +24 -0
  14. package/dist/docs/development/studio/01-introduction.md +2 -1
  15. package/dist/docs/development/studio/04-collaborate-on-a-canvas.md +10 -2
  16. package/dist/docs/development/studio/05-connect-your-agent.md +4 -0
  17. package/dist/docs/development/studio/07-reference.md +40 -7
  18. package/dist/docs/development/studio/draw-with-ai.md +96 -0
  19. package/dist/eventcatalog.cjs +1 -1
  20. package/dist/eventcatalog.js +5 -5
  21. package/dist/generate.cjs +1 -1
  22. package/dist/generate.js +3 -3
  23. package/dist/utils/cli-logger.cjs +1 -1
  24. package/dist/utils/cli-logger.js +2 -2
  25. package/eventcatalog/src/components/ChatPanel/ChatPanel.tsx +113 -8
  26. package/eventcatalog/src/components/ChatPanel/panel-width.ts +28 -0
  27. package/eventcatalog/src/components/MDX/Steps/Step.astro +4 -2
  28. package/eventcatalog/src/components/SchemaExplorer/DiffViewer.tsx +2 -103
  29. package/eventcatalog/src/components/SchemaExplorer/diff-dark-styles.ts +104 -0
  30. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +71 -71
  31. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +1 -1
  32. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +25 -3
  33. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +1 -1
  34. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +1 -1
  35. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/changelog/_ChangelogPage.astro +297 -0
  36. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/changelog/_index.data.spec.ts +101 -0
  37. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/changelog/_index.data.ts +76 -47
  38. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/changelog/index.astro +4 -168
  39. package/eventcatalog/src/pages/docs/services/[id]/[version]/changelog/index.astro +14 -0
  40. package/eventcatalog/src/pages/docs/services/[id]/changelog/index.astro +3 -3
  41. package/eventcatalog/src/utils/node-graphs/domain-levels-node-graph.ts +112 -28
  42. package/eventcatalog/src/utils/node-graphs/systems-node-graph.ts +24 -10
  43. package/package.json +4 -4
@@ -94,6 +94,7 @@ import {
94
94
  isGroupType,
95
95
  type Hotspot,
96
96
  type HotspotDraft,
97
+ type HotspotPlace,
97
98
  type HotspotFields,
98
99
  type HotspotSeverity,
99
100
  type HotspotSpot,
@@ -699,6 +700,8 @@ function Canvas({
699
700
  const [lastCopied, setLastCopied] = useState<ClipboardContent | null>(null);
700
701
  // Nodes to copy instead of the selection (copying from the context menu)
701
702
  const copyOnly = useRef<string[] | null>(null);
703
+ // Whether the menu's copy got onto the clipboard (Cut only deletes once it has)
704
+ const copiedFromMenu = useRef(false);
702
705
 
703
706
  /** Adds copies (as one change, so one undo takes them back) and selects them instead of what was selected */
704
707
  const paste = useStableCallback((content: ClipboardContent, place: { at: XYPosition } | { offset: XYPosition }) => {
@@ -731,6 +734,7 @@ function Canvas({
731
734
  event.clipboardData.setData('text/plain', content.nodes.map((node) => getNodeName(node.type, node.data)).join('\n'));
732
735
  pastes.current = 0;
733
736
  setLastCopied(content);
737
+ if (fromMenu) copiedFromMenu.current = true;
734
738
  if (event.type === 'cut') flow.deleteNodes(ids);
735
739
  };
736
740
  const onPaste = (event: ClipboardEvent) => {
@@ -1192,6 +1196,10 @@ function Canvas({
1192
1196
  setHotspotDraft(null);
1193
1197
  });
1194
1198
  const cancelHotspot = useCallback(() => setHotspotDraft(null), []);
1199
+ // The hotspot being added, dragged somewhere else before it's added: onto a node, or a place on the level shown
1200
+ const moveHotspotDraft = useStableCallback((to: HotspotPlace) =>
1201
+ setHotspotDraft((draft) => draft && { ...(to.nodeId ? { nodeId: to.nodeId } : placeLevel()), position: to.position })
1202
+ );
1195
1203
  const deleteHotspot = useStableCallback((id: string) => flow.deleteHotspot(id));
1196
1204
  // Dragged onto a node (they move with it), or to a place; open where it lands if it was open
1197
1205
  const moveHotspots = useStableCallback((ids: string[], to: { nodeId?: string; position: XYPosition }) => {
@@ -1213,8 +1221,8 @@ function Canvas({
1213
1221
  const width = node ? (node.measured?.width ?? node.width ?? 0) : 0;
1214
1222
  void setCenter(at.x + width / 2 + 120, at.y + 80, { zoom: Math.max(getZoom(), 0.8), duration: 300 });
1215
1223
  setHotspotDraft(null);
1224
+ // (the list stays open, so they can go through them one by one)
1216
1225
  setOpenHotspot(node ? `node:${node.id}` : `place:${hotspot.id}`);
1217
- setListingHotspots(false);
1218
1226
  });
1219
1227
  const nameOfNode = useStableCallback((id: string) => {
1220
1228
  const node = nodesRef.current.find((candidate) => candidate.id === id);
@@ -1725,6 +1733,13 @@ function Canvas({
1725
1733
  flow.deleteNodes(menuTargets());
1726
1734
  setContextMenu(null);
1727
1735
  });
1736
+ // Copies, then deletes, as ⌘X does: only on L3, where copying works (so it's not offered on L1 and L2)
1737
+ const cutFromMenu = useStableCallback(() => {
1738
+ copiedFromMenu.current = false;
1739
+ copyFromMenu();
1740
+ // Not copied (the browser refused, or there was nothing to copy): nothing's deleted, as with ⌘X
1741
+ if (copiedFromMenu.current) deleteFromMenu();
1742
+ });
1728
1743
 
1729
1744
  const openThread = useStableCallback((thread: Thread) => {
1730
1745
  setListingComments(false);
@@ -1841,7 +1856,7 @@ function Canvas({
1841
1856
  ref={canvasRef}
1842
1857
  className={`eventcatalog-visualizer studio-canvas relative flex-1 min-w-0 ${commentMode ? 'comment-mode' : ''} ${
1843
1858
  transitioning ? 'is-layout-transition' : ''
1844
- } ${placing || tool === 'comment' || tool === 'hotspot' ? 'is-placing' : ''} ${tool === 'hand' ? 'is-panning' : ''}`}
1859
+ } ${placing || tool === 'comment' || tool === 'hotspot' ? 'is-placing' : ''} ${tool === 'hotspot' ? 'is-placing-hotspot' : ''} ${tool === 'hand' ? 'is-panning' : ''}`}
1845
1860
  onPointerMove={trackPointer}
1846
1861
  onPointerLeave={clearPointer}
1847
1862
  onDoubleClick={onCanvasDoubleClick}
@@ -1938,6 +1953,7 @@ function Canvas({
1938
1953
  onUpdate={flow.updateHotspot}
1939
1954
  onDelete={deleteHotspot}
1940
1955
  onGo={goToHotspot}
1956
+ onMoveDraft={moveHotspotDraft}
1941
1957
  />
1942
1958
  </ReactFlow>
1943
1959
 
@@ -2051,7 +2067,12 @@ function Canvas({
2051
2067
  </button>
2052
2068
  </div>
2053
2069
  <div className="min-h-0 flex-1 overflow-y-auto">
2054
- <HotspotList hotspots={flow.hotspots} nameOf={nameOfNode} onOpen={goToHotspot} />
2070
+ <HotspotList
2071
+ hotspots={flow.hotspots}
2072
+ nameOf={nameOfNode}
2073
+ onOpen={goToHotspot}
2074
+ goOnHover={!hotspotDraft}
2075
+ />
2055
2076
  </div>
2056
2077
  </div>
2057
2078
  )}
@@ -2081,6 +2102,7 @@ function Canvas({
2081
2102
  onAddNote={noteFromMenu}
2082
2103
  onEditDetails={editFromMenu}
2083
2104
  onCopy={copyFromMenu}
2105
+ onCut={level === 3 ? cutFromMenu : undefined}
2084
2106
  onDuplicate={duplicateFromMenu}
2085
2107
  onPaste={lastCopied ? pasteFromMenu : undefined}
2086
2108
  onReorder={reorderFromMenu}
@@ -277,7 +277,7 @@ const createCanvasTools = (get: () => CanvasWebMcpOptions, getStage: () => Agent
277
277
  defineTool({
278
278
  name: 'remove_from_canvas',
279
279
  title: 'Remove from the canvas',
280
- description: 'Removes nodes (and their connections) or connections. Only remove what the user asked for.',
280
+ description: 'Removes nodes (and their connections and hotspots) or connections. Only remove what the user asked for.',
281
281
  input: z.object({ nodeIds: z.array(z.string()).optional(), connectionIds: z.array(z.string()).optional() }),
282
282
  annotations: { consequentialHint: true },
283
283
  execute: async ({ nodeIds = [], connectionIds = [] }) => {
@@ -372,7 +372,7 @@ export function registerCanvasTools(server: McpServer, { catalogUrl, userAgent }
372
372
  {
373
373
  title: 'Remove from a canvas',
374
374
  description:
375
- 'Removes nodes (and their connections) or connections from a canvas. Comments on removed nodes stay on the canvas. Only remove what the user asked for or what you added.',
375
+ 'Removes nodes (and their connections and hotspots) or connections from a canvas. Comments on removed nodes stay on the canvas. Only remove what the user asked for or what you added.',
376
376
  inputSchema: z.object({
377
377
  canvasId,
378
378
  nodeIds: z.array(z.string()).optional(),
@@ -0,0 +1,297 @@
1
+ ---
2
+ import Footer from '@layouts/Footer.astro';
3
+ import { getChangeLogs } from '@utils/collections/changelogs';
4
+ import { render, getEntry } from 'astro:content';
5
+ import mdxComponents from '@components/MDX/components';
6
+ import 'diff2html/bundles/css/diff2html.min.css';
7
+ import { getDiffDarkStyles } from '@components/SchemaExplorer/diff-dark-styles';
8
+
9
+ import { buildUrl } from '@utils/url-builder';
10
+ import { getPreviousVersion, getResourceNoun } from '@utils/collections/util';
11
+ import { getDiffsForCurrentAndPreviousVersion } from '@utils/file-diffs';
12
+ import { isIconPath, resolveIconUrl } from '@utils/icon';
13
+ import VerticalSideBarLayout from '@layouts/VerticalSideBarLayout.astro';
14
+ import Badge from '@components/Badge.astro';
15
+ import { ClientRouter } from 'astro:transitions';
16
+ import { HistoryIcon } from 'lucide-react';
17
+
18
+ interface Props {
19
+ changelogOwner: any;
20
+ }
21
+
22
+ const props = Astro.props.changelogOwner;
23
+
24
+ let collectionItem = props;
25
+ const logs = await getChangeLogs(props);
26
+
27
+ const { data } = props;
28
+ const latestVersion = data.latestVersion;
29
+
30
+ const renderedLogs = logs.map(async (log) => {
31
+ const logEntry = await getEntry('changelogs', log.id);
32
+ const { Content } = await render(logEntry as any);
33
+ return {
34
+ Content,
35
+ ...log,
36
+ };
37
+ });
38
+
39
+ const logsToRender = await Promise.all(renderedLogs);
40
+
41
+ const logListPromise = logsToRender.map(async (log) => {
42
+ const currentLogVersion = log.data.version;
43
+ const previousLogVersion = log.data.version ? getPreviousVersion(log.data.version, data.versions || []) : '';
44
+ return {
45
+ id: log.id,
46
+ // Anchor for the version, linked from the "Versions" list on the right
47
+ anchor: `v${log.data.version}`,
48
+ url: buildUrl(`/docs/${props.collection}/${props.data.id}/${log.data.version}`),
49
+ isLatest: log.data.version === latestVersion,
50
+ version: log.data.version,
51
+ previousVersion: previousLogVersion,
52
+ createdAt: log.data.createdAt,
53
+ badges: log.data.badges || [],
54
+ Content: log.Content,
55
+ diffs:
56
+ currentLogVersion && previousLogVersion
57
+ ? await getDiffsForCurrentAndPreviousVersion(
58
+ currentLogVersion,
59
+ previousLogVersion,
60
+ collectionItem.data.id,
61
+ props.allCollectionItems
62
+ )
63
+ : [],
64
+ };
65
+ });
66
+
67
+ const logList = await Promise.all(logListPromise);
68
+
69
+ const friendlyCollectionName = getResourceNoun(props.collection);
70
+ const headerIconSrc = isIconPath(props.data.styles?.icon) ? resolveIconUrl(props.data.styles!.icon!) : null;
71
+
72
+ const formatDate = (date?: Date) =>
73
+ date?.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' });
74
+
75
+ const releaseCount = `${logList.length} ${logList.length === 1 ? 'release' : 'releases'}`;
76
+ ---
77
+
78
+ <VerticalSideBarLayout title={`${props.data.name} | Changelog`} description={props.data.summary}>
79
+ <main class="flex docs-layout min-h-full bg-[rgb(var(--ec-page-bg))]">
80
+ <div class="flex docs-layout w-full">
81
+ <div class="w-full min-w-0 lg:mr-2 xl:pr-12 py-8 bg-[rgb(var(--ec-page-bg))]">
82
+ <div class="border-b border-[rgb(var(--ec-page-border))] md:pb-3 pt-4">
83
+ <div class="flex flex-col gap-4">
84
+ <span class="text-xs md:text-sm font-semibold text-[rgb(var(--ec-accent))]">Changelog</span>
85
+ <div class="flex items-center gap-3">
86
+ {
87
+ headerIconSrc && (
88
+ <img src={headerIconSrc} alt={props.data.name} class="w-8 h-8 md:w-10 md:h-10 object-contain rounded-md shrink-0" />
89
+ )
90
+ }
91
+ <h2 id="doc-page-header" class="text-2xl md:text-4xl font-bold text-[rgb(var(--ec-page-text))]">
92
+ {props.data.name}
93
+ </h2>
94
+ </div>
95
+ </div>
96
+ <p class="text-base pt-4 text-[rgb(var(--ec-page-text-muted))] font-light">
97
+ What changed in each version of {props.data.name}, newest first.
98
+ </p>
99
+ <div class="flex flex-wrap items-center gap-x-3 gap-y-2 pt-5 text-xs text-[rgb(var(--ec-page-text-muted))]">
100
+ <span>{releaseCount}</span>
101
+ {
102
+ latestVersion && (
103
+ <>
104
+ <span aria-hidden="true" class="h-3 w-px bg-[rgb(var(--ec-page-border))]" />
105
+ <span>Latest v{latestVersion}</span>
106
+ </>
107
+ )
108
+ }
109
+ </div>
110
+ </div>
111
+
112
+ {
113
+ logList.length === 0 && (
114
+ <div class="mt-8 rounded-lg border border-dashed border-[rgb(var(--ec-page-border))] px-6 py-10 text-center">
115
+ <HistoryIcon className="mx-auto h-6 w-6 text-[rgb(var(--ec-icon-color))]" strokeWidth={1.5} />
116
+ <p class="mt-3 text-sm font-semibold text-[rgb(var(--ec-page-text))]">No changelogs yet</p>
117
+ <p class="mt-1 text-sm text-[rgb(var(--ec-page-text-muted))]">
118
+ Add a <code>changelog.mdx</code> file next to this {friendlyCollectionName} to record what changed.
119
+ </p>
120
+ </div>
121
+ )
122
+ }
123
+
124
+ <ol class="changelog-timeline pt-4">
125
+ {
126
+ logList.map((log, index) => (
127
+ <li id={log.anchor} class="changelog-release relative scroll-mt-20 pl-8 pt-8">
128
+ {index !== logList.length - 1 && (
129
+ <span aria-hidden="true" class="absolute left-[5px] top-[2.9rem] -bottom-[2.6rem] w-px bg-[rgb(var(--ec-page-border))]" />
130
+ )}
131
+ <span
132
+ aria-hidden="true"
133
+ class={`absolute left-0 top-[2.6rem] h-[11px] w-[11px] rounded-full ring-4 ring-[rgb(var(--ec-page-bg))] ${
134
+ log.isLatest ? 'bg-[rgb(var(--ec-accent))]' : 'bg-[rgb(var(--ec-page-border))]'
135
+ }`}
136
+ />
137
+ <div class="flex flex-wrap items-center gap-x-3 gap-y-2">
138
+ <a
139
+ href={log.url}
140
+ class="text-xl font-bold text-[rgb(var(--ec-page-text))] hover:text-[rgb(var(--ec-accent))] transition-colors"
141
+ >
142
+ v{log.version}
143
+ </a>
144
+ {log.isLatest && (
145
+ <span class="rounded-full bg-[rgb(var(--ec-accent-subtle))] px-2 py-0.5 text-xs font-medium text-[rgb(var(--ec-accent))]">
146
+ Latest
147
+ </span>
148
+ )}
149
+ {log.createdAt && (
150
+ <time datetime={log.createdAt.toISOString()} class="text-sm text-[rgb(var(--ec-page-text-muted))]">
151
+ {formatDate(log.createdAt)}
152
+ </time>
153
+ )}
154
+ </div>
155
+ {log.badges.filter(Boolean).length > 0 && (
156
+ <div class="flex flex-wrap gap-1.5 pt-3">
157
+ {log.badges.filter(Boolean).map((badge: any) => (
158
+ <Badge badge={badge} />
159
+ ))}
160
+ </div>
161
+ )}
162
+ <div class="prose prose-md pt-2 pb-4 w-full text-[15px]">
163
+ <log.Content components={mdxComponents(props)} />
164
+ </div>
165
+ {log.diffs && log.diffs.length > 0 && (
166
+ <details class="changelog-diffs group mb-4 rounded-lg border border-[rgb(var(--ec-page-border))] overflow-hidden">
167
+ <summary class="flex cursor-pointer list-none items-center justify-between gap-3 bg-[rgb(var(--ec-content-hover))] px-4 py-2.5 text-sm">
168
+ <span class="text-[rgb(var(--ec-page-text))]">
169
+ <span class="font-semibold">v{log.previousVersion}</span>
170
+ <span class="mx-2 text-[rgb(var(--ec-page-text-muted))]">&rarr;</span>
171
+ <span class="font-semibold">v{log.version}</span>
172
+ </span>
173
+ <span class="text-xs text-[rgb(var(--ec-page-text-muted))]">
174
+ {log.diffs.length} {log.diffs.length === 1 ? 'file changed' : 'files changed'}
175
+ <span class="ml-1 inline-block transition-transform group-open:rotate-90">&rsaquo;</span>
176
+ </span>
177
+ </summary>
178
+ <div class="space-y-4 border-t border-[rgb(var(--ec-page-border))] p-2">
179
+ {log.diffs.map((diff) => (
180
+ <div set:html={diff} />
181
+ ))}
182
+ </div>
183
+ </details>
184
+ )}
185
+ </li>
186
+ ))
187
+ }
188
+ </ol>
189
+ <Footer />
190
+ </div>
191
+ {
192
+ logList.length > 0 && (
193
+ <aside class="hidden xl:block sticky top-[4rem] self-start w-[280px] max-h-[calc(100vh-4rem)] overflow-y-auto py-2 flex-shrink-0 pr-4 bg-[rgb(var(--ec-page-bg))]">
194
+ <div class="mt-8">
195
+ <h3 class="text-xs text-[rgb(var(--ec-page-text))] font-semibold capitalize flex items-center gap-2 mb-4">
196
+ <HistoryIcon className="w-4 h-4" />
197
+ Versions
198
+ </h3>
199
+ <nav id="changelog-versions" class="text-xs border-l border-[rgb(var(--ec-page-border))]">
200
+ {logList.map((log) => (
201
+ <a
202
+ href={`#${log.anchor}`}
203
+ data-version-anchor={log.anchor}
204
+ class="flex items-center justify-between gap-2 py-1.5 pl-3 pr-2.5 leading-5 text-[rgb(var(--ec-page-text-muted))] hover:text-[rgb(var(--ec-page-text))] border-l-2 border-transparent -ml-px transition-all duration-200"
205
+ >
206
+ <span>
207
+ v{log.version}
208
+ {log.isLatest && <span class="ml-1 text-[rgb(var(--ec-page-text-muted))]">(latest)</span>}
209
+ </span>
210
+ {log.createdAt && (
211
+ <span class="shrink-0 text-[11px] text-[rgb(var(--ec-page-text-muted))]">
212
+ {log.createdAt.toISOString().split('T')[0]}
213
+ </span>
214
+ )}
215
+ </a>
216
+ ))}
217
+ </nav>
218
+ </div>
219
+ </aside>
220
+ )
221
+ }
222
+ </div>
223
+
224
+ <style is:global set:html={getDiffDarkStyles(":root[data-theme='dark'] .changelog-diffs")} />
225
+ <style is:global>
226
+ .docs-layout .prose {
227
+ max-width: none;
228
+ overflow: auto;
229
+ }
230
+ .changelog-diffs summary::-webkit-details-marker {
231
+ display: none;
232
+ }
233
+ #changelog-versions a.is-active {
234
+ color: rgb(var(--ec-accent));
235
+ border-color: rgb(var(--ec-accent));
236
+ font-weight: 500;
237
+ }
238
+ </style>
239
+
240
+ <script>
241
+ declare global {
242
+ interface Window {
243
+ __changelogVersionsScroll?: (event: Event) => void;
244
+ }
245
+ }
246
+
247
+ // Highlight the version in view in the "Versions" list, like "On this page" does for headings.
248
+ function setupVersionList() {
249
+ if (window.__changelogVersionsScroll) {
250
+ document.removeEventListener('scroll', window.__changelogVersionsScroll, true);
251
+ window.__changelogVersionsScroll = undefined;
252
+ }
253
+
254
+ const nav = document.getElementById('changelog-versions');
255
+ if (!nav) return;
256
+
257
+ const links = Array.from(nav.querySelectorAll<HTMLAnchorElement>('a[data-version-anchor]'));
258
+ const setActive = (anchor: string) => {
259
+ links.forEach((link) => link.classList.toggle('is-active', link.dataset.versionAnchor === anchor));
260
+ };
261
+
262
+ let paused = false;
263
+ links.forEach((link) =>
264
+ link.addEventListener('click', () => {
265
+ setActive(link.dataset.versionAnchor!);
266
+ paused = true;
267
+ setTimeout(() => (paused = false), 800);
268
+ })
269
+ );
270
+
271
+ // Each release is one tall block, so pick the last one whose top has scrolled past the
272
+ // top third of the screen, or the last release once the page is scrolled to the bottom.
273
+ const releases = Array.from(document.querySelectorAll<HTMLElement>('.changelog-release'));
274
+ const onScroll = (event: Event) => {
275
+ // Ignore other scrolling areas, like the sidebar
276
+ if (paused || (event.target instanceof HTMLElement && !event.target.contains(releases[0]))) return;
277
+ const scroller =
278
+ event.target instanceof HTMLElement ? event.target : (document.scrollingElement as HTMLElement | null);
279
+ const atBottom = scroller && scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 4;
280
+ const current = atBottom
281
+ ? releases[releases.length - 1]
282
+ : releases.filter((release) => release.getBoundingClientRect().top <= window.innerHeight / 3).pop();
283
+ setActive((current ?? releases[0])?.id ?? '');
284
+ };
285
+
286
+ window.__changelogVersionsScroll = onScroll;
287
+ document.addEventListener('scroll', onScroll, { capture: true, passive: true });
288
+
289
+ setActive(decodeURIComponent(location.hash.slice(1)) || links[0]?.dataset.versionAnchor || '');
290
+ }
291
+
292
+ // The page uses the ClientRouter, so this fires on the first load and after every navigation.
293
+ document.addEventListener('astro:page-load', setupVersionList);
294
+ </script>
295
+ </main>
296
+ <ClientRouter />
297
+ </VerticalSideBarLayout>
@@ -0,0 +1,101 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { getLatestServiceChangelogPaths, Page, ServicePage } from './_index.data';
3
+
4
+ const COLLECTIONS = ['agents', 'events', 'commands', 'queries', 'services', 'domains', 'systems', 'flows', 'containers'];
5
+
6
+ const mocks = vi.hoisted(() => ({
7
+ isSSR: vi.fn(),
8
+ isChangelogEnabled: vi.fn(),
9
+ loaders: {} as Record<string, ReturnType<typeof vi.fn>>,
10
+ getServices: vi.fn(),
11
+ }));
12
+
13
+ vi.mock('@utils/feature', () => ({
14
+ isSSR: () => mocks.isSSR(),
15
+ isChangelogEnabled: () => mocks.isChangelogEnabled(),
16
+ }));
17
+ vi.mock('@utils/changelog-resource-badge', () => ({
18
+ changelogResourceCollections: ['agents', 'events', 'commands', 'queries', 'services', 'domains', 'systems', 'flows', 'containers'],
19
+ }));
20
+ vi.mock('@utils/collections/services', () => ({
21
+ getServices: () => mocks.getServices(),
22
+ }));
23
+ vi.mock('@utils/page-loaders/page-data-loader', () => ({
24
+ pageDataLoader: new Proxy({}, { get: (_target, type: string) => mocks.loaders[type] }),
25
+ }));
26
+
27
+ const item = (id: string, version: string) => ({ collection: 'any', data: { id, version } });
28
+
29
+ describe('Changelog routes', () => {
30
+ beforeEach(() => {
31
+ mocks.isSSR.mockReset().mockReturnValue(false);
32
+ mocks.isChangelogEnabled.mockReset().mockReturnValue(true);
33
+ mocks.loaders = Object.fromEntries(COLLECTIONS.map((type) => [type, vi.fn().mockResolvedValue([item(`${type}-1`, '1.0.0')])]));
34
+ });
35
+
36
+ it('gives every type but services a path on the generic route', async () => {
37
+ const paths = await Page.getStaticPaths();
38
+
39
+ expect(paths.map(({ params }) => params.type)).toEqual(COLLECTIONS.filter((type) => type !== 'services'));
40
+ expect(paths[0].params).toEqual({ type: 'agents', id: 'agents-1', version: '1.0.0' });
41
+ expect(mocks.loaders.services).not.toHaveBeenCalled();
42
+ });
43
+
44
+ it('gives one path per service id and version on the service route', async () => {
45
+ const orders1 = item('Orders', '1.0.0');
46
+ mocks.loaders.services.mockResolvedValue([orders1, item('Orders', '2.0.0'), item('Billing', '1.0.0')]);
47
+
48
+ const paths = await ServicePage.getStaticPaths();
49
+
50
+ expect(paths.map(({ params }) => params)).toEqual([
51
+ { id: 'Orders', version: '1.0.0' },
52
+ { id: 'Orders', version: '2.0.0' },
53
+ { id: 'Billing', version: '1.0.0' },
54
+ ]);
55
+ expect(paths[0].props).toMatchObject({ type: 'services', ...orders1 });
56
+ });
57
+
58
+ it('has no static paths on a server', async () => {
59
+ mocks.isSSR.mockReturnValue(true);
60
+
61
+ expect(await Page.getStaticPaths()).toEqual([]);
62
+ expect(await ServicePage.getStaticPaths()).toEqual([]);
63
+ });
64
+
65
+ it('has no static paths when the changelog is disabled', async () => {
66
+ mocks.isChangelogEnabled.mockReturnValue(false);
67
+
68
+ expect(await Page.getStaticPaths()).toEqual([]);
69
+ expect(await ServicePage.getStaticPaths()).toEqual([]);
70
+ });
71
+
72
+ it('forces the services type when fetching on the service route', async () => {
73
+ mocks.loaders.services.mockResolvedValue([item('Orders', '1.0.0')]);
74
+
75
+ const data = await ServicePage.getData({ params: { id: 'Orders', version: '1.0.0', type: 'events' }, props: {} } as any);
76
+
77
+ expect(data).toMatchObject({ type: 'services', data: { id: 'Orders', version: '1.0.0' } });
78
+ expect(mocks.loaders.events).not.toHaveBeenCalled();
79
+ });
80
+
81
+ it('returns nothing for an unknown version or a disabled changelog', async () => {
82
+ mocks.loaders.services.mockResolvedValue([item('Orders', '1.0.0')]);
83
+
84
+ await expect(ServicePage.getData({ params: { id: 'Orders', version: '9.9.9' }, props: {} } as any)).resolves.toBeUndefined();
85
+
86
+ mocks.isChangelogEnabled.mockReturnValue(false);
87
+ await expect(ServicePage.getData({ params: { id: 'Orders', version: '1.0.0' }, props: {} } as any)).resolves.toBeUndefined();
88
+ });
89
+
90
+ it('gives the version-less service changelog alias a path for the latest version of each service', async () => {
91
+ mocks.loaders.services.mockResolvedValue([item('Orders', '1.0.0'), item('Orders', '2.0.0'), item('Billing', '1.0.0')]);
92
+ mocks.getServices.mockResolvedValue([item('Orders', '2.0.0'), item('Billing', '1.0.0')]);
93
+
94
+ const paths = await getLatestServiceChangelogPaths();
95
+
96
+ expect(paths.map(({ params, props }) => ({ params, redirectVersion: props?.redirectVersion }))).toEqual([
97
+ { params: { id: 'Orders' }, redirectVersion: '2.0.0' },
98
+ { params: { id: 'Billing' }, redirectVersion: '1.0.0' },
99
+ ]);
100
+ });
101
+ });
@@ -2,61 +2,90 @@ import { isSSR, isChangelogEnabled } from '@utils/feature';
2
2
  import { changelogResourceCollections } from '@utils/changelog-resource-badge';
3
3
  import { HybridPage } from '@utils/page-loaders/hybrid-page';
4
4
  import type { PageTypes } from '@types';
5
+ import { getLatestServicePaths } from '../../../../services/_latest-version-route';
5
6
 
6
- export class Page extends HybridPage {
7
- static get prerender(): boolean {
8
- return !isSSR();
9
- }
7
+ // Services have their own route (pages/docs/services/[id]/[version]/changelog), because the
8
+ // version-less alias /docs/services/[id]/[docType]/[docId] outranks this generic route and
9
+ // would otherwise catch /docs/services/{id}/{version}/changelog.
10
+ const GENERIC_ROUTE_TYPES = changelogResourceCollections.filter((type) => type !== 'services');
10
11
 
11
- static async getStaticPaths(): Promise<Array<{ params: any; props: any }>> {
12
- if (isSSR() || !isChangelogEnabled()) {
13
- return [];
12
+ type RouteParams = { type?: string; id?: string; version?: string };
13
+
14
+ const createChangelogPage = ({
15
+ types,
16
+ toParams,
17
+ typeOf,
18
+ }: {
19
+ types: PageTypes[];
20
+ toParams: (type: PageTypes, item: any) => RouteParams;
21
+ typeOf: (params: RouteParams) => string | undefined;
22
+ }) =>
23
+ class extends HybridPage {
24
+ static get prerender(): boolean {
25
+ return !isSSR();
14
26
  }
15
27
 
16
- const { pageDataLoader } = await import('@utils/page-loaders/page-data-loader');
17
-
18
- const itemTypes: PageTypes[] = [...changelogResourceCollections];
19
- const allItems = await Promise.all(itemTypes.map((type) => pageDataLoader[type]()));
20
-
21
- return allItems.flatMap((items, index) =>
22
- items.map((item) => ({
23
- params: {
24
- type: itemTypes[index],
25
- id: item.data.id,
26
- version: item.data.version,
27
- },
28
- props: {
29
- type: itemTypes[index],
30
- allCollectionItems: items,
31
- ...item,
32
- },
33
- }))
34
- );
35
- }
36
-
37
- protected static async fetchData(params: any) {
38
- const { type, id, version } = params;
39
-
40
- if (!type || !id || !version || !isChangelogEnabled()) {
41
- return null;
28
+ static async getStaticPaths(): Promise<Array<{ params: any; props: any }>> {
29
+ if (isSSR() || !isChangelogEnabled()) {
30
+ return [];
31
+ }
32
+
33
+ const { pageDataLoader } = await import('@utils/page-loaders/page-data-loader');
34
+
35
+ const allItems = await Promise.all(types.map((type) => pageDataLoader[type]()));
36
+
37
+ return allItems.flatMap((items, index) =>
38
+ items.map((item) => ({
39
+ params: toParams(types[index], item),
40
+ props: {
41
+ type: types[index],
42
+ allCollectionItems: items,
43
+ ...item,
44
+ },
45
+ }))
46
+ );
42
47
  }
43
48
 
44
- const { pageDataLoader } = await import('@utils/page-loaders/page-data-loader');
49
+ protected static async fetchData(params: RouteParams) {
50
+ const type = typeOf(params);
51
+ const { id, version } = params;
45
52
 
46
- // Get all items of the specified type
47
- const items = await pageDataLoader[type as PageTypes]();
53
+ if (!type || !id || !version || !isChangelogEnabled()) {
54
+ return null;
55
+ }
48
56
 
49
- // Find the specific item by id and version
50
- const item = items.find((i) => i.data.id === id && i.data.version === version);
57
+ const { pageDataLoader } = await import('@utils/page-loaders/page-data-loader');
51
58
 
52
- if (!item) {
53
- return null;
59
+ const items = await pageDataLoader[type as PageTypes]();
60
+ const item = items.find((i) => i.data.id === id && i.data.version === version);
61
+
62
+ if (!item) {
63
+ return null;
64
+ }
65
+
66
+ return {
67
+ type,
68
+ allCollectionItems: items,
69
+ ...item,
70
+ };
54
71
  }
72
+ };
73
+
74
+ export const Page = createChangelogPage({
75
+ types: GENERIC_ROUTE_TYPES as PageTypes[],
76
+ toParams: (type, item) => ({ type, id: item.data.id, version: item.data.version }),
77
+ typeOf: (params) => params.type,
78
+ });
79
+
80
+ export const ServicePage = createChangelogPage({
81
+ types: ['services'],
82
+ toParams: (_type, item) => ({ id: item.data.id, version: item.data.version }),
83
+ typeOf: () => 'services',
84
+ });
55
85
 
56
- return {
57
- type,
58
- allCollectionItems: items,
59
- ...item,
60
- };
61
- }
62
- }
86
+ // Static paths for the version-less /docs/services/[id]/changelog alias. The service route has no
87
+ // [type] segment, so put it back for getLatestServicePaths, which only keeps services.
88
+ export const getLatestServiceChangelogPaths = async () => {
89
+ const paths = await ServicePage.getStaticPaths();
90
+ return getLatestServicePaths(paths.map(({ params, props }) => ({ params: { ...params, type: 'services' }, props })));
91
+ };