@eventcatalog/core 5.5.1 → 5.6.0
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.
- package/dist/analytics/analytics.cjs +1 -1
- package/dist/analytics/analytics.js +2 -2
- package/dist/analytics/log-build.cjs +1 -1
- package/dist/analytics/log-build.js +5 -5
- package/dist/{chunk-6KR5ZLEQ.js → chunk-7TTNTHHN.js} +10 -10
- package/dist/{chunk-K5QKWVI3.js → chunk-DEO4QEGO.js} +1 -1
- package/dist/{chunk-GPIKYBQ5.js → chunk-ETS4G6RR.js} +1 -1
- package/dist/{chunk-GZLVDQPM.js → chunk-T3U27UXC.js} +1 -1
- package/dist/{chunk-EBZQJP7F.js → chunk-XBRKEQ66.js} +1 -1
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/docs/development/customization/05-resource-sidebar.md +1 -1
- package/dist/docs/development/guides/domains/09-reference.md +5 -1
- package/dist/docs/development/guides/resources/schemas/03-add-specifications-to-services/04-add-generic-specifications.md +139 -0
- package/dist/docs/development/guides/resources/schemas/03-add-specifications-to-services/_category_.json +1 -1
- package/dist/docs/development/guides/resources/services/08-reference.md +6 -1
- package/dist/eventcatalog.cjs +1 -1
- package/dist/eventcatalog.js +10 -10
- package/dist/generate.cjs +1 -1
- package/dist/generate.js +3 -3
- package/dist/utils/cli-logger.cjs +1 -1
- package/dist/utils/cli-logger.js +2 -2
- package/eventcatalog/public/icons/specification.svg +1 -0
- package/eventcatalog/src/components/ChatPanel/ChatPanel.tsx +38 -20
- package/eventcatalog/src/components/Grids/specification-utils.ts +7 -1
- package/eventcatalog/src/components/MDX/ResourceRef/ResourceRef.astro +1 -1
- package/eventcatalog/src/components/SchemaExplorer/SchemaCodeBlock.tsx +58 -0
- package/eventcatalog/src/components/SchemaExplorer/SchemaCodeModal.tsx +2 -1
- package/eventcatalog/src/components/SchemaExplorer/SchemaContentViewer.tsx +38 -76
- package/eventcatalog/src/components/SchemaExplorer/SchemaDetailsPanel.tsx +15 -11
- package/eventcatalog/src/components/SchemaExplorer/SchemaExplorer.tsx +171 -142
- package/eventcatalog/src/components/SchemaExplorer/SchemaListItem.tsx +5 -8
- package/eventcatalog/src/components/SchemaExplorer/types.ts +2 -0
- package/eventcatalog/src/components/SchemaExplorer/utils.ts +70 -1
- package/eventcatalog/src/content.config.ts +3 -1
- package/eventcatalog/src/features/ai/chat-api.ts +6 -1
- package/eventcatalog/src/features/mcp/apps/architecture-diagram/view.tsx +8 -2
- package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +2 -2
- package/eventcatalog/src/features/mcp/apps/generated/canvas.html +67 -67
- package/eventcatalog/src/features/mcp/apps/generated/viewer.html +72 -72
- package/eventcatalog/src/features/mcp/apps/schema-viewer/view.tsx +2 -1
- package/eventcatalog/src/features/mcp/apps/shared/app-view.tsx +24 -5
- package/eventcatalog/src/features/mcp/mcp-apps.ts +6 -1
- package/eventcatalog/src/features/mcp/mcp-server.ts +2 -0
- package/eventcatalog/src/features/studio/server/canvas-mcp.ts +2 -0
- package/eventcatalog/src/features/studio/tool-sync.ts +67 -3
- package/eventcatalog/src/features/tools/catalog-tools.ts +8 -3
- package/eventcatalog/src/pages/.well-known/api-catalog.ts +1 -11
- package/eventcatalog/src/pages/api-catalog/specifications/[collection]/[id]/[version]/[specification].ts +1 -11
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/generic/[filename].astro +137 -0
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/generic/_SpecificationSource.tsx +44 -0
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/generic/_[filename].data.ts +75 -0
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/index.astro +14 -1
- package/eventcatalog/src/stores/sidebar-store/builders/domain.ts +10 -1
- package/eventcatalog/src/stores/sidebar-store/builders/service.ts +8 -1
- package/eventcatalog/src/stores/sidebar-store/custom-sidebar.ts +3 -1
- package/eventcatalog/src/utils/collections/util.ts +38 -1
- package/eventcatalog/src/utils/icon.ts +8 -0
- package/eventcatalog/src/utils/node-graphs/utils/utils.ts +5 -2
- package/eventcatalog/src/utils/schema-explorer.ts +7 -2
- package/package.json +6 -6
|
@@ -12,6 +12,7 @@ import AvroSchemaViewer from '../../../../components/SchemaExplorer/AvroSchemaVi
|
|
|
12
12
|
import ProtobufSchemaViewer from '../../../../components/SchemaExplorer/ProtobufSchemaViewer';
|
|
13
13
|
import { SchemaCode } from './schema-code';
|
|
14
14
|
import { RESOURCE_TYPE_LABELS } from '../shared/resource-types';
|
|
15
|
+
import { fullscreenStyle } from '../shared/app-view';
|
|
15
16
|
import type { SchemaViewerPayload, SchemaViewerSchema } from './shared';
|
|
16
17
|
|
|
17
18
|
const INLINE_MAX_HEIGHT = '560px';
|
|
@@ -75,7 +76,7 @@ export function SchemaViewerView({ app, payload, hostContext }: SchemaViewerView
|
|
|
75
76
|
'inline-flex h-7 items-center gap-1.5 rounded-md border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-card-bg))] px-2.5 text-xs font-medium text-[rgb(var(--ec-page-text))] transition-colors hover:border-[rgb(var(--ec-accent))] hover:text-[rgb(var(--ec-accent))]';
|
|
76
77
|
|
|
77
78
|
return (
|
|
78
|
-
<div className=
|
|
79
|
+
<div className="flex flex-col" style={isFullscreen ? fullscreenStyle(hostContext) : undefined}>
|
|
79
80
|
<div className="flex items-center justify-between gap-3 border-b border-[rgb(var(--ec-page-border))] px-3 py-2">
|
|
80
81
|
<div className="flex min-w-0 items-center gap-2 text-[13px]">
|
|
81
82
|
<span className="shrink-0 rounded bg-[rgb(var(--ec-accent-subtle))] px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-[rgb(var(--ec-accent))]">
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Connecting the EventCatalog viewer MCP App to the host, loading the tool result's payload, following the
|
|
3
3
|
* host's theme and display mode, and mounting the view.
|
|
4
4
|
*/
|
|
5
|
-
import { useEffect, useState, type ComponentType } from 'react';
|
|
5
|
+
import { useEffect, useState, type ComponentType, type CSSProperties } from 'react';
|
|
6
6
|
import { config as configureZod } from 'zod';
|
|
7
7
|
import { createRoot } from 'react-dom/client';
|
|
8
8
|
import type { App, McpUiHostContext } from '@modelcontextprotocol/ext-apps';
|
|
@@ -20,6 +20,24 @@ const applyTheme = (context?: McpUiHostContext) => {
|
|
|
20
20
|
if (context?.theme) document.documentElement.setAttribute('data-theme', context.theme);
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* The view full screen: the height of the frame, kept clear of what the host draws over it on phones (the notch,
|
|
25
|
+
* the home indicator, the message box), which it reports as safe area insets
|
|
26
|
+
*/
|
|
27
|
+
export const fullscreenStyle = (context?: McpUiHostContext): CSSProperties => {
|
|
28
|
+
const insets = context?.safeAreaInsets;
|
|
29
|
+
return {
|
|
30
|
+
height: '100vh',
|
|
31
|
+
boxSizing: 'border-box',
|
|
32
|
+
...(insets && {
|
|
33
|
+
paddingTop: insets.top,
|
|
34
|
+
paddingRight: insets.right,
|
|
35
|
+
paddingBottom: insets.bottom,
|
|
36
|
+
paddingLeft: insets.left,
|
|
37
|
+
}),
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
|
|
23
41
|
type McpAppViewOptions<Payload> = {
|
|
24
42
|
name: string;
|
|
25
43
|
/** Key of the payload in the model-facing tool result's `_meta` */
|
|
@@ -61,8 +79,9 @@ export function useMcpAppView<Payload>(options: McpAppViewOptions<Payload>) {
|
|
|
61
79
|
appInfo: { name: options.name, version: '1.0.0' },
|
|
62
80
|
// The views fit inline and have a full screen button (the server asks hosts to open them full screen)
|
|
63
81
|
capabilities: { availableDisplayModes: ['inline', 'fullscreen'] },
|
|
82
|
+
// Listeners go on before the view connects: hosts send the tool's result as soon as it has
|
|
64
83
|
onAppCreated: (createdApp) => {
|
|
65
|
-
createdApp.
|
|
84
|
+
createdApp.addEventListener('toolresult', (result) => {
|
|
66
85
|
if (result.isError) {
|
|
67
86
|
setError(result.content?.find((item) => item.type === 'text')?.text ?? options.loadErrorMessage);
|
|
68
87
|
return;
|
|
@@ -70,11 +89,11 @@ export function useMcpAppView<Payload>(options: McpAppViewOptions<Payload>) {
|
|
|
70
89
|
loadPayload(createdApp, result, options)
|
|
71
90
|
.then(setPayload)
|
|
72
91
|
.catch((loadError) => setError(String(loadError?.message ?? loadError)));
|
|
73
|
-
};
|
|
74
|
-
createdApp.
|
|
92
|
+
});
|
|
93
|
+
createdApp.addEventListener('hostcontextchanged', (context) => {
|
|
75
94
|
applyTheme(context);
|
|
76
95
|
setHostContext((previous) => ({ ...previous, ...context }));
|
|
77
|
-
};
|
|
96
|
+
});
|
|
78
97
|
},
|
|
79
98
|
});
|
|
80
99
|
|
|
@@ -54,6 +54,8 @@ export type McpAppDefinition<Shape extends z.ZodRawShape, ViewShape extends z.Zo
|
|
|
54
54
|
csp?: { resourceDomains?: string[]; connectDomains?: string[] };
|
|
55
55
|
/** How hosts open the view: ChatGPT reads these before loading it, so it can open straight in that mode */
|
|
56
56
|
displayModes?: { preferred: McpUiDisplayMode; available: McpUiDisplayMode[] };
|
|
57
|
+
/** Whether the host frames the view with a border and background (hosts' defaults differ, e.g. Claude's web and mobile apps) */
|
|
58
|
+
prefersBorder?: boolean;
|
|
57
59
|
};
|
|
58
60
|
/**
|
|
59
61
|
* Ties every result to one view (ChatGPT's `openai/widgetSessionId`): the host updates the view it already
|
|
@@ -151,7 +153,10 @@ export function registerMcpApp<Shape extends z.ZodRawShape, ViewShape extends z.
|
|
|
151
153
|
mimeType: RESOURCE_MIME_TYPE,
|
|
152
154
|
text: await loadView(),
|
|
153
155
|
_meta: {
|
|
154
|
-
|
|
156
|
+
ui: {
|
|
157
|
+
...(app.resource.csp && { csp: app.resource.csp }),
|
|
158
|
+
...(app.resource.prefersBorder !== undefined && { prefersBorder: app.resource.prefersBorder }),
|
|
159
|
+
},
|
|
155
160
|
...(app.resource.displayModes && {
|
|
156
161
|
'openai/ui': {
|
|
157
162
|
preferredDisplayMode: app.resource.displayModes.preferred,
|
|
@@ -512,6 +512,8 @@ function createMcpServer(
|
|
|
512
512
|
csp: { resourceDomains: [catalogUrl] },
|
|
513
513
|
// Diagrams and schemas need the room, so hosts that can open the viewer full screen straight away
|
|
514
514
|
displayModes: { preferred: 'fullscreen', available: ['inline', 'fullscreen'] },
|
|
515
|
+
// The viewer has its own background: no frame around it, on the web and on phones alike
|
|
516
|
+
prefersBorder: false,
|
|
515
517
|
},
|
|
516
518
|
// One viewer per server: each result updates the viewer the host already shows
|
|
517
519
|
sessionId: scope ? `eventcatalog:${scope.ref.kind}:${scope.ref.id}` : 'eventcatalog',
|
|
@@ -124,6 +124,8 @@ export function registerCanvasTools(server: McpServer, { catalogUrl, userAgent }
|
|
|
124
124
|
// The view joins the canvas over the collaboration WebSocket, and shows icons served by this EventCatalog
|
|
125
125
|
csp: { connectDomains: [new URL(socketUrl).origin, catalogUrl], resourceDomains: [catalogUrl] },
|
|
126
126
|
displayModes: { preferred: 'fullscreen', available: ['inline', 'fullscreen'] },
|
|
127
|
+
// The canvas has its own background: no frame around it, on the web and on phones alike
|
|
128
|
+
prefersBorder: false,
|
|
127
129
|
},
|
|
128
130
|
metaKey: CANVAS_META_KEY,
|
|
129
131
|
tool: {
|
|
@@ -6,7 +6,8 @@ import type { Status } from './hooks/use-studio-flow';
|
|
|
6
6
|
* Syncing a canvas through MCP tool calls, for when a view can't open the collaboration WebSocket
|
|
7
7
|
* (e.g. an MCP App inside a chat whose sandbox blocks it). Each call sends the server what it doesn't have
|
|
8
8
|
* yet (by its state vector) and our presence, and gets back what we don't have and everyone's presence.
|
|
9
|
-
* Slower than the WebSocket (it polls), but works wherever the chat can call the MCP server's tools.
|
|
9
|
+
* Slower than the WebSocket (it polls), but works wherever the chat can call the MCP server's tools. It polls less
|
|
10
|
+
* while nothing changes, and rarely while the view is out of sight (chats keep every view they showed running).
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
export type SyncRequest = {
|
|
@@ -33,6 +34,11 @@ export const fromBase64 = (text: string) => Uint8Array.from(atob(text), (char) =
|
|
|
33
34
|
/** Applied with this origin, so undo (which tracks local changes) leaves other people's changes alone */
|
|
34
35
|
const REMOTE = Symbol('tool-sync');
|
|
35
36
|
|
|
37
|
+
/** Waiting longest between syncs while nothing changes on either side */
|
|
38
|
+
const IDLE_INTERVAL_MS = 5000;
|
|
39
|
+
/** Between syncs while the view is out of sight (slowed, not stopped: a host that wrongly reports it hidden still syncs) */
|
|
40
|
+
const OUT_OF_SIGHT_INTERVAL_MS = 15_000;
|
|
41
|
+
|
|
36
42
|
export function startToolSync(
|
|
37
43
|
doc: Y.Doc,
|
|
38
44
|
awareness: Awareness,
|
|
@@ -44,32 +50,90 @@ export function startToolSync(
|
|
|
44
50
|
}: { canvasId: string; sync: ToolSync; onStatus: (status: Status) => void; intervalMs?: number }
|
|
45
51
|
) {
|
|
46
52
|
let serverVector: Uint8Array | undefined;
|
|
53
|
+
let lastServerVector: string | undefined;
|
|
54
|
+
let lastAwareness: string | undefined;
|
|
47
55
|
let stopped = false;
|
|
56
|
+
let syncing = false;
|
|
48
57
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
58
|
+
let dueAt = 0;
|
|
59
|
+
// Slower while nothing changes (a chat keeps every view it showed running, each polling), back to quick on a change
|
|
60
|
+
let delay = intervalMs;
|
|
61
|
+
// Hosts keep a chat's earlier views running: one scrolled out of sight, or in a hidden tab, syncs rarely
|
|
62
|
+
let inView = true;
|
|
63
|
+
const isShown = () => inView && document.visibilityState !== 'hidden';
|
|
64
|
+
|
|
65
|
+
const schedule = (ms: number) => {
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
dueAt = Date.now() + ms;
|
|
68
|
+
timer = setTimeout(tick, ms);
|
|
69
|
+
};
|
|
49
70
|
|
|
50
71
|
const tick = async () => {
|
|
72
|
+
timer = undefined;
|
|
73
|
+
if (stopped || syncing) return;
|
|
74
|
+
syncing = true;
|
|
51
75
|
try {
|
|
76
|
+
const update = Y.encodeStateAsUpdate(doc, serverVector);
|
|
52
77
|
const response = await sync({
|
|
53
78
|
canvasId,
|
|
54
79
|
clientId: doc.clientID,
|
|
55
80
|
stateVector: toBase64(Y.encodeStateVector(doc)),
|
|
56
|
-
update: toBase64(
|
|
81
|
+
update: toBase64(update),
|
|
57
82
|
awareness: toBase64(encodeAwarenessUpdate(awareness, [doc.clientID])),
|
|
58
83
|
});
|
|
59
84
|
if (stopped) return;
|
|
60
85
|
Y.applyUpdate(doc, fromBase64(response.update), REMOTE);
|
|
61
86
|
serverVector = fromBase64(response.stateVector);
|
|
62
87
|
if (response.awareness) applyAwarenessUpdate(awareness, fromBase64(response.awareness), REMOTE);
|
|
88
|
+
// Anything new either way (the canvas, or anyone's presence) keeps it quick
|
|
89
|
+
const changed = response.stateVector !== lastServerVector || response.awareness !== lastAwareness;
|
|
90
|
+
lastServerVector = response.stateVector;
|
|
91
|
+
lastAwareness = response.awareness;
|
|
92
|
+
delay = changed ? intervalMs : Math.min(delay * 1.5, IDLE_INTERVAL_MS);
|
|
63
93
|
onStatus('connected');
|
|
64
94
|
} catch {
|
|
65
95
|
if (!stopped) onStatus('disconnected');
|
|
96
|
+
} finally {
|
|
97
|
+
syncing = false;
|
|
66
98
|
}
|
|
67
|
-
|
|
99
|
+
// Out of sight: rarely, and straight away again once it's shown (see show)
|
|
100
|
+
if (!stopped) schedule(isShown() ? delay : Math.max(delay, OUT_OF_SIGHT_INTERVAL_MS));
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Our own changes and presence go out within the quick interval, however long it's been waiting
|
|
104
|
+
const wake = (_update: unknown, origin: unknown) => {
|
|
105
|
+
if (origin === REMOTE || stopped) return;
|
|
106
|
+
delay = intervalMs;
|
|
107
|
+
if (timer && dueAt > Date.now() + intervalMs) schedule(intervalMs);
|
|
68
108
|
};
|
|
109
|
+
doc.on('update', wake);
|
|
110
|
+
awareness.on('update', wake);
|
|
111
|
+
|
|
112
|
+
const show = () => {
|
|
113
|
+
if (stopped || !isShown() || syncing || dueAt <= Date.now() + intervalMs) return;
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
delay = intervalMs;
|
|
116
|
+
void tick();
|
|
117
|
+
};
|
|
118
|
+
document.addEventListener('visibilitychange', show);
|
|
119
|
+
// Seen from inside the chat's frame: whether the view is on the screen at all
|
|
120
|
+
const observer =
|
|
121
|
+
typeof IntersectionObserver === 'undefined'
|
|
122
|
+
? undefined
|
|
123
|
+
: new IntersectionObserver(([entry]) => {
|
|
124
|
+
inView = entry?.isIntersecting ?? true;
|
|
125
|
+
show();
|
|
126
|
+
});
|
|
127
|
+
observer?.observe(document.documentElement);
|
|
128
|
+
|
|
69
129
|
void tick();
|
|
70
130
|
|
|
71
131
|
return () => {
|
|
72
132
|
stopped = true;
|
|
73
133
|
clearTimeout(timer);
|
|
134
|
+
doc.off('update', wake);
|
|
135
|
+
awareness.off('update', wake);
|
|
136
|
+
document.removeEventListener('visibilitychange', show);
|
|
137
|
+
observer?.disconnect();
|
|
74
138
|
};
|
|
75
139
|
}
|
|
@@ -5,7 +5,12 @@
|
|
|
5
5
|
import { getCollection } from 'astro:content';
|
|
6
6
|
import { z } from 'zod';
|
|
7
7
|
import { getSchemaFormatFromURL } from '@utils/collections/schemas';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
getItemsFromCollectionByIdAndSemverOrLatest,
|
|
10
|
+
getSpecificationCodeLanguage,
|
|
11
|
+
processSpecifications,
|
|
12
|
+
versionMatches,
|
|
13
|
+
} from '@utils/collections/util';
|
|
9
14
|
import { getUbiquitousLanguageWithSubdomains } from '@utils/collections/domains';
|
|
10
15
|
import { getAbsoluteFilePathForAstroFile } from '@utils/files';
|
|
11
16
|
import fs from 'node:fs';
|
|
@@ -464,7 +469,7 @@ export async function getSchemaForResource(params: { resourceId: string; resourc
|
|
|
464
469
|
...processSpecifications(resource.data.specifications).map((spec) => ({
|
|
465
470
|
name: spec.name,
|
|
466
471
|
path: spec.path,
|
|
467
|
-
format: spec.type,
|
|
472
|
+
format: spec.type === 'generic' ? getSpecificationCodeLanguage(spec.path) : spec.type,
|
|
468
473
|
})),
|
|
469
474
|
];
|
|
470
475
|
|
|
@@ -1332,7 +1337,7 @@ export const toolDescriptions = {
|
|
|
1332
1337
|
getMessagesProducedOrConsumedByResource:
|
|
1333
1338
|
'Use this tool to get the messages (events, commands, queries) a service, agent, domain or system sends and receives. Each message is resolved to its catalog id, version, name and summary; messages that are referenced but not documented in the catalog are marked notFound. For a domain or system, this includes what the services, agents, systems and subdomains inside it send and receive, and each message lists `by`: the resources that send or receive it (e.g. services/order-service). Omit the version to use the latest version of the resource.',
|
|
1334
1339
|
getSchemaForResource:
|
|
1335
|
-
'Use this tool to get the schema or specifications (openapi or
|
|
1340
|
+
'Use this tool to get the schema or specifications (openapi, asyncapi, graphql or generic specifications such as XML, JSON or YAML files) for a resource by its id and version',
|
|
1336
1341
|
findResourcesByOwner:
|
|
1337
1342
|
'Use this tool to find all resources (agents, services, events, commands, etc.) owned by a specific team or user',
|
|
1338
1343
|
getProducersOfMessage:
|
|
@@ -2,7 +2,7 @@ import type { APIRoute } from 'astro';
|
|
|
2
2
|
import yaml from 'js-yaml';
|
|
3
3
|
import { getServices, getSpecificationsForService } from '@utils/collections/services';
|
|
4
4
|
import { getDomains, getSpecificationsForDomain } from '@utils/collections/domains';
|
|
5
|
-
import type
|
|
5
|
+
import { getSpecificationMediaType, type ProcessedSpecification } from '@utils/collections/util';
|
|
6
6
|
import { buildUrl } from '@utils/url-builder';
|
|
7
7
|
import { readResourceFile } from '@utils/resource-files';
|
|
8
8
|
import { isEventCatalogMCPEnabled } from '@utils/feature';
|
|
@@ -26,16 +26,6 @@ type ApiCatalogResource = Awaited<ReturnType<typeof getServices>>[number] | Awai
|
|
|
26
26
|
|
|
27
27
|
const absoluteUrl = (request: Request, pathOrUrl: string) => new URL(pathOrUrl, request.url).toString();
|
|
28
28
|
|
|
29
|
-
const getSpecificationMediaType = (specification: ProcessedSpecification) => {
|
|
30
|
-
const extension = specification.filename.split('.').pop()?.toLowerCase();
|
|
31
|
-
|
|
32
|
-
if (specification.type === 'graphql') return 'application/graphql';
|
|
33
|
-
if (extension === 'json') return 'application/json';
|
|
34
|
-
if (extension === 'yaml' || extension === 'yml') return 'application/yaml';
|
|
35
|
-
|
|
36
|
-
return 'text/plain';
|
|
37
|
-
};
|
|
38
|
-
|
|
39
29
|
const getSpecificationIdentifier = (specification: ProcessedSpecification) => {
|
|
40
30
|
return `${specification.type}-${Buffer.from(specification.path).toString('base64url')}`;
|
|
41
31
|
};
|
|
@@ -2,7 +2,7 @@ import type { APIRoute } from 'astro';
|
|
|
2
2
|
import { getCollection } from 'astro:content';
|
|
3
3
|
import { getSpecificationsForDomain } from '@utils/collections/domains';
|
|
4
4
|
import { getSpecificationsForService } from '@utils/collections/services';
|
|
5
|
-
import type
|
|
5
|
+
import { getSpecificationMediaType, type ProcessedSpecification } from '@utils/collections/util';
|
|
6
6
|
import { readResourceFile } from '@utils/resource-files';
|
|
7
7
|
|
|
8
8
|
type SupportedCollection = 'domains' | 'services';
|
|
@@ -11,16 +11,6 @@ const isSupportedCollection = (collection: string | undefined): collection is Su
|
|
|
11
11
|
return collection === 'domains' || collection === 'services';
|
|
12
12
|
};
|
|
13
13
|
|
|
14
|
-
const getSpecificationMediaType = (specification: ProcessedSpecification) => {
|
|
15
|
-
const extension = specification.filename.split('.').pop()?.toLowerCase();
|
|
16
|
-
|
|
17
|
-
if (specification.type === 'graphql') return 'application/graphql';
|
|
18
|
-
if (extension === 'json') return 'application/json';
|
|
19
|
-
if (extension === 'yaml' || extension === 'yml') return 'application/yaml';
|
|
20
|
-
|
|
21
|
-
return 'text/plain';
|
|
22
|
-
};
|
|
23
|
-
|
|
24
14
|
const getSpecificationIdentifier = (specification: ProcessedSpecification) => {
|
|
25
15
|
return `${specification.type}-${Buffer.from(specification.path).toString('base64url')}`;
|
|
26
16
|
};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
---
|
|
2
|
+
import VerticalSideBarLayout from '@layouts/VerticalSideBarLayout.astro';
|
|
3
|
+
import Footer from '@layouts/Footer.astro';
|
|
4
|
+
import { Page } from './_[filename].data';
|
|
5
|
+
import SpecificationSource from './_SpecificationSource';
|
|
6
|
+
import { buildUrl } from '@utils/url-builder';
|
|
7
|
+
import { getGenericSpecificationIcon, resolveIconUrl } from '@utils/icon';
|
|
8
|
+
import { readResourceFile, resourceFileExists } from '@utils/resource-files';
|
|
9
|
+
import { resolveHeaders } from '@utils/remote-spec';
|
|
10
|
+
import Admonition from '@components/MDX/Admonition';
|
|
11
|
+
import NewVersionBanner from '@components/NewVersionBanner.astro';
|
|
12
|
+
import Badge from '@components/Badge.astro';
|
|
13
|
+
import { getResourceTypeBadge } from '@utils/collections/resource-type-badge';
|
|
14
|
+
import { getResourceNoun, getSpecificationCodeLanguage } from '@utils/collections/util';
|
|
15
|
+
|
|
16
|
+
export const prerender = Page.prerender;
|
|
17
|
+
export const getStaticPaths = Page.getStaticPaths;
|
|
18
|
+
|
|
19
|
+
// Get data
|
|
20
|
+
const pageData = await Page.getData(Astro);
|
|
21
|
+
if (!pageData) return Astro.rewrite('/404');
|
|
22
|
+
const {
|
|
23
|
+
collection,
|
|
24
|
+
data,
|
|
25
|
+
filename,
|
|
26
|
+
filenameWithoutExtension,
|
|
27
|
+
path: relativeSpecPath,
|
|
28
|
+
specificationName,
|
|
29
|
+
icon,
|
|
30
|
+
headers = {},
|
|
31
|
+
} = pageData;
|
|
32
|
+
|
|
33
|
+
// Generic specifications are shown as source, without parsing them
|
|
34
|
+
const isRemote = /^https?:\/\//i.test(relativeSpecPath);
|
|
35
|
+
let fileExists = isRemote || resourceFileExists(pageData, relativeSpecPath);
|
|
36
|
+
let content = '';
|
|
37
|
+
|
|
38
|
+
if (isRemote) {
|
|
39
|
+
// A remote file that can't be fetched is shown as not found, like a missing local file, so it doesn't fail the build
|
|
40
|
+
const response = await fetch(relativeSpecPath, { headers: resolveHeaders(headers) }).catch(() => null);
|
|
41
|
+
|
|
42
|
+
if (response?.ok) {
|
|
43
|
+
content = await response.text();
|
|
44
|
+
} else {
|
|
45
|
+
fileExists = false;
|
|
46
|
+
console.warn(`Failed to fetch specification ${relativeSpecPath}: ${response ? response.status : 'network error'}`);
|
|
47
|
+
}
|
|
48
|
+
} else if (fileExists) {
|
|
49
|
+
content = readResourceFile(pageData, relativeSpecPath) ?? '';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const iconURL = resolveIconUrl(getGenericSpecificationIcon(icon));
|
|
53
|
+
|
|
54
|
+
// Create comprehensive page title
|
|
55
|
+
const pageTitle = `${collection} | ${data.name} | ${specificationName}`.replace(/^\w/, (c) => c.toUpperCase());
|
|
56
|
+
|
|
57
|
+
const badges = [
|
|
58
|
+
getResourceTypeBadge(collection),
|
|
59
|
+
{
|
|
60
|
+
textColor: 'gray',
|
|
61
|
+
content: specificationName,
|
|
62
|
+
iconURL,
|
|
63
|
+
id: 'generic-specification-badge',
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
// Index only the latest version
|
|
68
|
+
const pagefindAttributes =
|
|
69
|
+
data.version === data.latestVersion
|
|
70
|
+
? {
|
|
71
|
+
'data-pagefind-body': '',
|
|
72
|
+
'data-pagefind-meta': `title:${pageTitle}`,
|
|
73
|
+
}
|
|
74
|
+
: {};
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
<VerticalSideBarLayout title={pageTitle} description={`${specificationName} for ${data.name}`}>
|
|
78
|
+
<main class="flex docs-layout min-h-full bg-[rgb(var(--ec-page-bg))]" {...pagefindAttributes}>
|
|
79
|
+
<div class="flex docs-layout w-full">
|
|
80
|
+
<div class="w-full min-w-0 lg:mr-2 xl:pr-12 py-8 bg-[rgb(var(--ec-page-bg))]">
|
|
81
|
+
<!-- Title and subtitle sized like the resource's overview page, kept compact so the source starts high up -->
|
|
82
|
+
<div class="border-b border-[rgb(var(--ec-page-border))] pb-4">
|
|
83
|
+
<h1 class="text-2xl md:text-4xl font-bold text-[rgb(var(--ec-page-text))]">{data.name}</h1>
|
|
84
|
+
<p class="text-base pt-2 text-[rgb(var(--ec-page-text-muted))] font-light">{specificationName}</p>
|
|
85
|
+
<div class="flex flex-wrap gap-1.5 pt-3">
|
|
86
|
+
{badges.map((badge: any) => <Badge badge={badge} />)}
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<div data-pagefind-ignore>
|
|
91
|
+
{
|
|
92
|
+
data.version !== data.latestVersion && (
|
|
93
|
+
<NewVersionBanner
|
|
94
|
+
subject={getResourceNoun(collection)}
|
|
95
|
+
name={data.name}
|
|
96
|
+
latestOf={specificationName}
|
|
97
|
+
latestVersion={data.latestVersion}
|
|
98
|
+
href={buildUrl(`/docs/${collection}/${data.id}/${data.latestVersion}/generic/${filenameWithoutExtension}`)}
|
|
99
|
+
/>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
{
|
|
105
|
+
!fileExists && (
|
|
106
|
+
<Admonition type="warning" title="Specification not found">
|
|
107
|
+
<p>The specification file could not be found at {relativeSpecPath}.</p>
|
|
108
|
+
</Admonition>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
{
|
|
113
|
+
fileExists && (
|
|
114
|
+
<div class="mt-4 not-prose">
|
|
115
|
+
<SpecificationSource
|
|
116
|
+
client:load
|
|
117
|
+
name={`${data.name} - ${specificationName}`}
|
|
118
|
+
version={data.version}
|
|
119
|
+
code={content}
|
|
120
|
+
language={getSpecificationCodeLanguage(filename)}
|
|
121
|
+
/>
|
|
122
|
+
</div>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
<Footer />
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
</main>
|
|
130
|
+
|
|
131
|
+
<style is:global>
|
|
132
|
+
.docs-layout .prose {
|
|
133
|
+
max-width: none;
|
|
134
|
+
overflow: auto;
|
|
135
|
+
}
|
|
136
|
+
</style>
|
|
137
|
+
</VerticalSideBarLayout>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import SchemaCodeBlock from '@components/SchemaExplorer/SchemaCodeBlock';
|
|
3
|
+
import SchemaCodeModal from '@components/SchemaExplorer/SchemaCodeModal';
|
|
4
|
+
import { copyToClipboard } from '@components/SchemaExplorer/utils';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
/** The specification's name, shown in fullscreen */
|
|
8
|
+
name: string;
|
|
9
|
+
version: string;
|
|
10
|
+
code: string;
|
|
11
|
+
/** The syntax to highlight: json, yaml, xml or text */
|
|
12
|
+
language: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A generic specification's source, in the same code box as the Schema Explorer */
|
|
16
|
+
export default function SpecificationSource({ name, version, code, language }: Props) {
|
|
17
|
+
const [isCopied, setIsCopied] = useState(false);
|
|
18
|
+
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
19
|
+
|
|
20
|
+
const onCopy = async () => {
|
|
21
|
+
if (!(await copyToClipboard(code))) return;
|
|
22
|
+
setIsCopied(true);
|
|
23
|
+
setTimeout(() => setIsCopied(false), 2000);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<>
|
|
28
|
+
<SchemaCodeBlock
|
|
29
|
+
code={code}
|
|
30
|
+
extension={language}
|
|
31
|
+
onCopy={onCopy}
|
|
32
|
+
isCopied={isCopied}
|
|
33
|
+
onOpenFullscreen={() => setIsFullscreen(true)}
|
|
34
|
+
/>
|
|
35
|
+
<SchemaCodeModal
|
|
36
|
+
isOpen={isFullscreen}
|
|
37
|
+
onOpenChange={setIsFullscreen}
|
|
38
|
+
message={{ data: { name, version }, schemaExtension: language, schemaContent: code }}
|
|
39
|
+
onCopy={onCopy}
|
|
40
|
+
isCopied={isCopied}
|
|
41
|
+
/>
|
|
42
|
+
</>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// pages/docs/[type]/[id]/[version]/generic/[filename].astro
|
|
2
|
+
import { isSSR } from '@utils/feature';
|
|
3
|
+
import { HybridPage } from '@utils/page-loaders/hybrid-page';
|
|
4
|
+
import type { CollectionEntry } from 'astro:content';
|
|
5
|
+
import type { CollectionTypes, PageTypes } from '@types';
|
|
6
|
+
import type { ProcessedSpecification } from '@utils/collections/util';
|
|
7
|
+
|
|
8
|
+
const toPageProps = (type: PageTypes, spec: ProcessedSpecification, item: CollectionEntry<CollectionTypes>) => ({
|
|
9
|
+
type,
|
|
10
|
+
filenameWithoutExtension: spec.filenameWithoutExtension || spec.type,
|
|
11
|
+
filename: spec.filename || spec.type,
|
|
12
|
+
path: spec.path,
|
|
13
|
+
specificationName: spec.name,
|
|
14
|
+
icon: spec.icon,
|
|
15
|
+
headers: spec.headers,
|
|
16
|
+
...item,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export class Page extends HybridPage {
|
|
20
|
+
static get prerender(): boolean {
|
|
21
|
+
return !isSSR();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
static async getStaticPaths(): Promise<Array<{ params: any; props: any }>> {
|
|
25
|
+
if (isSSR()) {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const { pageDataLoader } = await import('@utils/page-loaders/page-data-loader');
|
|
30
|
+
const { getSpecificationsForService } = await import('@utils/collections/services');
|
|
31
|
+
|
|
32
|
+
const itemTypes: PageTypes[] = ['events', 'commands', 'queries', 'services', 'domains'];
|
|
33
|
+
const allItems = await Promise.all(itemTypes.map((type) => pageDataLoader[type]()));
|
|
34
|
+
|
|
35
|
+
return allItems.flatMap((items, index) =>
|
|
36
|
+
items.flatMap((item) =>
|
|
37
|
+
getSpecificationsForService(item)
|
|
38
|
+
.filter((spec) => spec.type === 'generic')
|
|
39
|
+
.map((spec) => ({
|
|
40
|
+
params: {
|
|
41
|
+
type: itemTypes[index],
|
|
42
|
+
id: item.data.id,
|
|
43
|
+
version: item.data.version,
|
|
44
|
+
filename: spec.filenameWithoutExtension || spec.type,
|
|
45
|
+
},
|
|
46
|
+
props: toPageProps(itemTypes[index], spec, item),
|
|
47
|
+
}))
|
|
48
|
+
)
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
protected static async fetchData(params: any) {
|
|
53
|
+
const { type, id, version, filename } = params;
|
|
54
|
+
|
|
55
|
+
if (!type || !id || !version || !filename) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const { pageDataLoader } = await import('@utils/page-loaders/page-data-loader');
|
|
60
|
+
const { getSpecificationsForService } = await import('@utils/collections/services');
|
|
61
|
+
|
|
62
|
+
const items = await pageDataLoader[type as PageTypes]();
|
|
63
|
+
const item = items.find((i) => i.data.id === id && i.data.version === version);
|
|
64
|
+
|
|
65
|
+
if (!item) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const spec = getSpecificationsForService(item).find(
|
|
70
|
+
(s) => s.type === 'generic' && (s.filenameWithoutExtension || s.type) === filename
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
return spec ? toPageProps(type, spec, item) : null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -65,7 +65,7 @@ import {
|
|
|
65
65
|
import { getSchemasFromResource } from '@utils/collections/schemas';
|
|
66
66
|
import { getIcon } from '@utils/badges';
|
|
67
67
|
import { buildUrl, buildEditUrlForResource } from '@utils/url-builder';
|
|
68
|
-
import { isIconPath, resolveIconUrl } from '@utils/icon';
|
|
68
|
+
import { getGenericSpecificationIcon, isIconPath, resolveIconUrl } from '@utils/icon';
|
|
69
69
|
import {
|
|
70
70
|
isEventCatalogChatVisible,
|
|
71
71
|
isMarkdownDownloadEnabled,
|
|
@@ -249,6 +249,7 @@ const getSpecificationBadges = () => {
|
|
|
249
249
|
const asyncapiSpecs = specifications.filter((spec) => spec.type === 'asyncapi');
|
|
250
250
|
const openapiSpecs = specifications.filter((spec) => spec.type === 'openapi');
|
|
251
251
|
const graphQLSpecs = specifications.filter((spec) => spec.type === 'graphql');
|
|
252
|
+
const genericSpecs = specifications.filter((spec) => spec.type === 'generic');
|
|
252
253
|
|
|
253
254
|
if (openapiSpecs.length > 0) {
|
|
254
255
|
for (const spec of openapiSpecs) {
|
|
@@ -290,6 +291,18 @@ const getSpecificationBadges = () => {
|
|
|
290
291
|
}
|
|
291
292
|
}
|
|
292
293
|
|
|
294
|
+
for (const spec of genericSpecs) {
|
|
295
|
+
badges.push({
|
|
296
|
+
content: spec.name,
|
|
297
|
+
iconURL: resolveIconUrl(getGenericSpecificationIcon(spec.icon)),
|
|
298
|
+
class: 'text-[rgb(var(--ec-page-text))] hover:underline',
|
|
299
|
+
id: 'generic-specification-badge',
|
|
300
|
+
url: buildUrl(
|
|
301
|
+
`/docs/${props.collection}/${props.data.id}/${props.data.version}/generic/${spec.filenameWithoutExtension}`
|
|
302
|
+
),
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
293
306
|
return badges;
|
|
294
307
|
};
|
|
295
308
|
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
import { isVisualiserEnabled, isChangelogEnabled } from '@utils/feature';
|
|
17
17
|
import { pluralizeMessageType } from '@utils/collections/messages';
|
|
18
18
|
import { getSpecificationsForDomain, hasUbiquitousLanguageTermsWithSubdomainsInCollection } from '@utils/collections/domains';
|
|
19
|
-
import { customIconFieldsForResource } from '@utils/icon';
|
|
19
|
+
import { customIconFieldsForResource, getGenericSpecificationIcon } from '@utils/icon';
|
|
20
20
|
import { resolveSidebarPages, toCustomSidebarContext, type SidebarSpec } from '../custom-sidebar';
|
|
21
21
|
|
|
22
22
|
// Sort resolved collection entries A-Z by their display name (falling back to id).
|
|
@@ -143,6 +143,7 @@ export const buildDomainSections = (
|
|
|
143
143
|
const openAPISpecifications = specifications.filter((specification) => specification.type === 'openapi');
|
|
144
144
|
const asyncAPISpecifications = specifications.filter((specification) => specification.type === 'asyncapi');
|
|
145
145
|
const graphQLSpecifications = specifications.filter((specification) => specification.type === 'graphql');
|
|
146
|
+
const genericSpecifications = specifications.filter((specification) => specification.type === 'generic');
|
|
146
147
|
const renderSpecifications = hasSpecifications && shouldRenderSideBarSection(domain, 'specifications');
|
|
147
148
|
|
|
148
149
|
// Resource subsections. These are built once and shared between the "Resources"
|
|
@@ -284,6 +285,14 @@ export const buildDomainSections = (
|
|
|
284
285
|
`/docs/domains/${domain.data.id}/${domain.data.version}/graphql/${specification.filenameWithoutExtension}`
|
|
285
286
|
),
|
|
286
287
|
})),
|
|
288
|
+
...genericSpecifications.map((specification) => ({
|
|
289
|
+
type: 'item',
|
|
290
|
+
title: specification.name,
|
|
291
|
+
leftIcon: getGenericSpecificationIcon(specification.icon),
|
|
292
|
+
href: buildUrl(
|
|
293
|
+
`/docs/domains/${domain.data.id}/${domain.data.version}/generic/${specification.filenameWithoutExtension}`
|
|
294
|
+
),
|
|
295
|
+
})),
|
|
287
296
|
] as ChildRef[],
|
|
288
297
|
}
|
|
289
298
|
: null,
|