@eventcatalog/core 4.0.6 → 4.1.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.
Files changed (44) 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/catalog-to-astro-content-directory.cjs +32 -1
  6. package/dist/catalog-to-astro-content-directory.js +2 -2
  7. package/dist/{chunk-7443A7LZ.js → chunk-2PPDVKNK.js} +1 -1
  8. package/dist/{chunk-ZYWUN3T3.js → chunk-2T6ESD3L.js} +1 -1
  9. package/dist/{chunk-O3Y2G6CY.js → chunk-CAJUIMDJ.js} +1 -1
  10. package/dist/{chunk-Z7BGWB5J.js → chunk-OQ36RUHT.js} +1 -1
  11. package/dist/{chunk-FHVBLKWN.js → chunk-PL4YZUAA.js} +1 -1
  12. package/dist/{chunk-ZXAF4JO7.js → chunk-RXZKZGDC.js} +1 -1
  13. package/dist/{chunk-WQNKHIDM.js → chunk-W3SAPOZU.js} +13 -1
  14. package/dist/{chunk-VC6VUJIX.js → chunk-W5JQON7Z.js} +20 -1
  15. package/dist/constants.cjs +1 -1
  16. package/dist/constants.js +1 -1
  17. package/dist/eventcatalog.cjs +33 -2
  18. package/dist/eventcatalog.config.d.cts +8 -0
  19. package/dist/eventcatalog.config.d.ts +8 -0
  20. package/dist/eventcatalog.js +8 -8
  21. package/dist/generate.cjs +1 -1
  22. package/dist/generate.js +3 -3
  23. package/dist/map-catalog-to-astro.cjs +20 -1
  24. package/dist/map-catalog-to-astro.js +1 -1
  25. package/dist/utils/cli-logger.cjs +1 -1
  26. package/dist/utils/cli-logger.js +2 -2
  27. package/dist/watcher.cjs +20 -1
  28. package/dist/watcher.js +2 -2
  29. package/eventcatalog/src/components/ChatPanel/ChatPanel.tsx +6 -2
  30. package/eventcatalog/src/components/MDX/RemoteFile.astro +5 -0
  31. package/eventcatalog/src/components/SchemaExplorer/DiffViewer.tsx +7 -9
  32. package/eventcatalog/src/components/SchemaExplorer/SchemaDetailsPanel.tsx +127 -41
  33. package/eventcatalog/src/components/SchemaExplorer/VersionHistoryModal.tsx +2 -2
  34. package/eventcatalog/src/components/SchemaExplorer/types.ts +4 -4
  35. package/eventcatalog/src/enterprise/custom-pages/routes.ts +142 -0
  36. package/eventcatalog/src/enterprise/feature.ts +3 -0
  37. package/eventcatalog/src/enterprise/integrations/eventcatalog-features.ts +84 -1
  38. package/eventcatalog/src/pages/index.astro +1 -1
  39. package/eventcatalog/src/toolkit/layouts/Layout.astro +25 -0
  40. package/eventcatalog/src/toolkit/utils/index.ts +26 -0
  41. package/eventcatalog/src/utils/feature.ts +1 -0
  42. package/eventcatalog/src/utils/json-schema-refs.ts +158 -0
  43. package/eventcatalog/tsconfig.json +5 -1
  44. package/package.json +3 -3
@@ -4,6 +4,7 @@ import JSONSchemaViewer from '@components/SchemaExplorer/JSONSchemaViewer';
4
4
  import { Code } from 'astro-expressive-code/components';
5
5
  import { isPrivateRemoteSchemaEnabled } from '@utils/feature';
6
6
  import { resolveTemplateVariables } from '@utils/remote-file';
7
+ import { resolveSchemaRefs } from '@utils/json-schema-refs';
7
8
 
8
9
  const {
9
10
  url,
@@ -89,6 +90,10 @@ try {
89
90
  if (isValidJSON(content)) {
90
91
  contentType = 'json';
91
92
  processedData = JSON.parse(content);
93
+ processedData = await resolveSchemaRefs(processedData, {
94
+ baseUrl: String(resolvedUrl),
95
+ headers: resolvedHeaders as HeadersInit,
96
+ });
92
97
 
93
98
  // Check if it's a JSON Schema
94
99
  isSchema = isJSONSchema(processedData);
@@ -122,10 +122,10 @@ export default function DiffViewer({ diffs, onOpenFullscreen, apiAccessEnabled =
122
122
  {isDark && <style dangerouslySetInnerHTML={{ __html: DIFF_DARK_STYLES }} />}
123
123
  <div className="mb-5 flex items-start justify-between">
124
124
  <div className="flex-1">
125
- <h3 className="text-base font-semibold text-[rgb(var(--ec-page-text))] mb-1">Version History</h3>
125
+ <h3 className="text-base font-semibold text-[rgb(var(--ec-page-text))] mb-1">Version Comparison</h3>
126
126
  <p className="text-sm text-[rgb(var(--ec-page-text-muted))]">
127
127
  {apiAccessEnabled
128
- ? `${diffs.length} version comparison${diffs.length !== 1 ? 's' : ''}`
128
+ ? `${diffs.length} selected comparison${diffs.length !== 1 ? 's' : ''}`
129
129
  : 'Compare schema versions side-by-side'}
130
130
  </p>
131
131
  </div>
@@ -142,21 +142,19 @@ export default function DiffViewer({ diffs, onOpenFullscreen, apiAccessEnabled =
142
142
  </div>
143
143
  {apiAccessEnabled ? (
144
144
  <div className={`space-y-6 ${isDark ? 'diff-dark-mode' : ''}`}>
145
- {diffs.map((diff, index) => (
145
+ {diffs.map((diff) => (
146
146
  <div
147
- key={`${diff.newerVersion}-${diff.olderVersion}`}
147
+ key={`${diff.fromVersion}-${diff.toVersion}`}
148
148
  className="border border-[rgb(var(--ec-page-border))] rounded-lg overflow-hidden"
149
149
  >
150
150
  <div className="bg-[rgb(var(--ec-content-hover))] border-b border-[rgb(var(--ec-page-border))] px-4 py-2.5">
151
151
  <div className="flex items-center justify-between">
152
152
  <div className="text-sm">
153
- <span className="font-semibold text-[rgb(var(--ec-page-text))]">v{diff.newerVersion}</span>
153
+ <span className="font-semibold text-[rgb(var(--ec-page-text))]">v{diff.fromVersion}</span>
154
154
  <span className="text-[rgb(var(--ec-page-text-muted))] mx-2">&rarr;</span>
155
- <span className="font-semibold text-[rgb(var(--ec-page-text))]">v{diff.olderVersion}</span>
155
+ <span className="font-semibold text-[rgb(var(--ec-page-text))]">v{diff.toVersion}</span>
156
156
  </div>
157
- <span className="text-xs text-[rgb(var(--ec-page-text-muted))]">
158
- {index === 0 ? 'Latest change' : `${index + 1} version${index + 1 !== 1 ? 's' : ''} ago`}
159
- </span>
157
+ <span className="text-xs text-[rgb(var(--ec-page-text-muted))]">Selected comparison</span>
160
158
  </div>
161
159
  </div>
162
160
  <div className="relative">
@@ -64,43 +64,70 @@ export default function SchemaDetailsPanel({
64
64
  const consumers = message.data.consumers || [];
65
65
  const filename = message.data.schemaPath?.split('/').pop() || `${message.data.id}.${ext || 'json'}`;
66
66
 
67
- // Generate diffs between all consecutive versions
68
- const allDiffs: VersionDiff[] = useMemo(() => {
69
- const diffs: VersionDiff[] = [];
70
- if (!hasMultipleVersions) return diffs;
71
-
72
- for (let i = 0; i < availableVersions.length - 1; i++) {
73
- const newerVersion = availableVersions[i];
74
- const olderVersion = availableVersions[i + 1];
75
-
76
- if (newerVersion.schemaContent && olderVersion.schemaContent) {
77
- const diff = Diff.createTwoFilesPatch(
78
- `v${olderVersion.data.version}`,
79
- `v${newerVersion.data.version}`,
80
- olderVersion.schemaContent,
81
- newerVersion.schemaContent,
82
- '',
83
- '',
84
- { context: 3 }
85
- );
86
-
87
- const diffHtml = html(diff, {
88
- drawFileList: false,
89
- matching: 'lines',
90
- outputFormat: 'side-by-side',
91
- });
92
-
93
- diffs.push({
94
- newerVersion: newerVersion.data.version,
95
- olderVersion: olderVersion.data.version,
96
- diffHtml,
97
- newerContent: newerVersion.schemaContent,
98
- olderContent: olderVersion.schemaContent,
99
- });
100
- }
101
- }
102
- return diffs;
103
- }, [availableVersions]);
67
+ const uniqueAvailableVersions = useMemo(
68
+ () =>
69
+ availableVersions.filter(
70
+ (version, index, versions) => versions.findIndex((item) => item.data.version === version.data.version) === index
71
+ ),
72
+ [availableVersions]
73
+ );
74
+ const defaultToVersion = uniqueAvailableVersions[0]?.data.version || '';
75
+ const defaultFromVersion = uniqueAvailableVersions[1]?.data.version || defaultToVersion;
76
+ const [diffFromVersion, setDiffFromVersion] = useState(defaultFromVersion);
77
+ const [diffToVersion, setDiffToVersion] = useState(defaultToVersion);
78
+ const schemaResourceKey = [
79
+ message.collection,
80
+ message.data.id,
81
+ message.specType || '',
82
+ message.specFilenameWithoutExtension || message.specName || '',
83
+ ].join(':');
84
+
85
+ useEffect(() => {
86
+ setDiffFromVersion(defaultFromVersion);
87
+ setDiffToVersion(defaultToVersion);
88
+ setIsDiffModalOpen(false);
89
+ }, [schemaResourceKey, defaultFromVersion, defaultToVersion]);
90
+
91
+ const diffFromItem = useMemo(
92
+ () => uniqueAvailableVersions.find((version) => version.data.version === diffFromVersion),
93
+ [diffFromVersion, uniqueAvailableVersions]
94
+ );
95
+ const diffToItem = useMemo(
96
+ () => uniqueAvailableVersions.find((version) => version.data.version === diffToVersion),
97
+ [diffToVersion, uniqueAvailableVersions]
98
+ );
99
+ const hasDiffFromContent = !!diffFromItem?.schemaContent?.trim();
100
+ const hasDiffToContent = !!diffToItem?.schemaContent?.trim();
101
+ const selectedDiff: VersionDiff | null = useMemo(() => {
102
+ if (!diffFromItem || !diffToItem) return null;
103
+ if (diffFromItem.data.version === diffToItem.data.version) return null;
104
+ if (!diffFromItem.schemaContent?.trim() || !diffToItem.schemaContent?.trim()) return null;
105
+
106
+ const diff = Diff.createTwoFilesPatch(
107
+ `v${diffFromItem.data.version}`,
108
+ `v${diffToItem.data.version}`,
109
+ diffFromItem.schemaContent,
110
+ diffToItem.schemaContent,
111
+ '',
112
+ '',
113
+ { context: 3 }
114
+ );
115
+
116
+ const diffHtml = html(diff, {
117
+ drawFileList: false,
118
+ matching: 'lines',
119
+ outputFormat: 'side-by-side',
120
+ });
121
+
122
+ return {
123
+ fromVersion: diffFromItem.data.version,
124
+ toVersion: diffToItem.data.version,
125
+ diffHtml,
126
+ fromContent: diffFromItem.schemaContent,
127
+ toContent: diffToItem.schemaContent,
128
+ };
129
+ }, [diffFromItem, diffToItem]);
130
+ const selectedDiffs = selectedDiff ? [selectedDiff] : [];
104
131
 
105
132
  // Check if this is a JSON schema
106
133
  const parsedSchema = useMemo(() => {
@@ -212,7 +239,7 @@ export default function SchemaDetailsPanel({
212
239
  if (examples.length > 0) {
213
240
  tabs.push({ id: 'examples', label: 'Usage Examples', icon: <BookOpenIcon className="h-3.5 w-3.5" /> });
214
241
  }
215
- if (allDiffs.length > 0) {
242
+ if (hasMultipleVersions) {
216
243
  tabs.push({ id: 'diff', label: 'Changes', icon: <ClockIcon className="h-3.5 w-3.5" /> });
217
244
  }
218
245
  tabs.push({ id: 'api', label: 'API', icon: <GlobeAltIcon className="h-3.5 w-3.5" /> });
@@ -341,8 +368,67 @@ export default function SchemaDetailsPanel({
341
368
  copiedId={copiedId}
342
369
  apiAccessEnabled={apiAccessEnabled}
343
370
  />
344
- ) : activeTab === 'diff' && allDiffs.length > 0 ? (
345
- <DiffViewer diffs={allDiffs} onOpenFullscreen={() => setIsDiffModalOpen(true)} apiAccessEnabled={apiAccessEnabled} />
371
+ ) : activeTab === 'diff' && hasMultipleVersions ? (
372
+ <div className="flex h-full min-h-0 flex-col overflow-hidden">
373
+ <div className="mb-4 flex flex-shrink-0 flex-col gap-3 rounded-lg border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-content-hover)/0.45)] p-3 sm:flex-row sm:items-end sm:justify-between">
374
+ <div className="grid flex-1 grid-cols-1 gap-3 sm:grid-cols-2">
375
+ <label className="flex flex-col gap-1.5">
376
+ <span className="text-[11px] font-medium uppercase tracking-[0.14em] text-[rgb(var(--ec-page-text-muted))]">
377
+ From
378
+ </span>
379
+ <select
380
+ value={diffFromVersion}
381
+ onChange={(event) => setDiffFromVersion(event.target.value)}
382
+ className="h-9 rounded-md border border-[rgb(var(--ec-dropdown-border))] bg-[rgb(var(--ec-dropdown-bg))] px-3 text-sm font-mono tabular-nums text-[rgb(var(--ec-page-text))] outline-hidden transition-colors focus:border-[rgb(var(--ec-accent))] focus:ring-1 focus:ring-[rgb(var(--ec-accent)/0.3)]"
383
+ >
384
+ {uniqueAvailableVersions.map((version) => (
385
+ <option key={`from-${version.data.version}`} value={version.data.version}>
386
+ v{version.data.version}
387
+ </option>
388
+ ))}
389
+ </select>
390
+ </label>
391
+ <label className="flex flex-col gap-1.5">
392
+ <span className="text-[11px] font-medium uppercase tracking-[0.14em] text-[rgb(var(--ec-page-text-muted))]">
393
+ To
394
+ </span>
395
+ <select
396
+ value={diffToVersion}
397
+ onChange={(event) => setDiffToVersion(event.target.value)}
398
+ className="h-9 rounded-md border border-[rgb(var(--ec-dropdown-border))] bg-[rgb(var(--ec-dropdown-bg))] px-3 text-sm font-mono tabular-nums text-[rgb(var(--ec-page-text))] outline-hidden transition-colors focus:border-[rgb(var(--ec-accent))] focus:ring-1 focus:ring-[rgb(var(--ec-accent)/0.3)]"
399
+ >
400
+ {uniqueAvailableVersions.map((version) => (
401
+ <option key={`to-${version.data.version}`} value={version.data.version}>
402
+ v{version.data.version}
403
+ </option>
404
+ ))}
405
+ </select>
406
+ </label>
407
+ </div>
408
+ <button
409
+ type="button"
410
+ onClick={() => setIsDiffModalOpen(true)}
411
+ disabled={!selectedDiff}
412
+ className="inline-flex h-9 items-center justify-center gap-1.5 rounded-md border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-dropdown-bg))] px-3 text-xs font-medium text-[rgb(var(--ec-page-text-muted))] transition-colors hover:bg-[rgb(var(--ec-content-hover))] hover:text-[rgb(var(--ec-page-text))] disabled:cursor-not-allowed disabled:opacity-50"
413
+ >
414
+ <ArrowTopRightOnSquareIcon className="h-3.5 w-3.5" />
415
+ Expand
416
+ </button>
417
+ </div>
418
+ <div className="min-h-0 flex-1 overflow-hidden">
419
+ {diffFromVersion === diffToVersion ? (
420
+ <div className="flex h-full items-center justify-center text-[rgb(var(--ec-page-text-muted))]">
421
+ <p className="text-sm">Select two different versions</p>
422
+ </div>
423
+ ) : !hasDiffFromContent || !hasDiffToContent ? (
424
+ <div className="flex h-full items-center justify-center text-[rgb(var(--ec-page-text-muted))]">
425
+ <p className="text-sm">No schema content available</p>
426
+ </div>
427
+ ) : (
428
+ <DiffViewer diffs={selectedDiffs} apiAccessEnabled={apiAccessEnabled} />
429
+ )}
430
+ </div>
431
+ </div>
346
432
  ) : (
347
433
  <SchemaContentViewer
348
434
  message={message}
@@ -507,7 +593,7 @@ export default function SchemaDetailsPanel({
507
593
  <VersionHistoryModal
508
594
  isOpen={isDiffModalOpen}
509
595
  onOpenChange={setIsDiffModalOpen}
510
- diffs={allDiffs}
596
+ diffs={selectedDiffs}
511
597
  messageName={message.data.name}
512
598
  apiAccessEnabled={apiAccessEnabled}
513
599
  />
@@ -28,7 +28,7 @@ export default function VersionHistoryModal({
28
28
  <div className="flex items-center gap-3">
29
29
  <ArrowsPointingOutIcon className="h-6 w-6 text-[rgb(var(--ec-icon-color))]" />
30
30
  <div>
31
- <Dialog.Title className="text-xl font-semibold text-[rgb(var(--ec-page-text))]">Version History</Dialog.Title>
31
+ <Dialog.Title className="text-xl font-semibold text-[rgb(var(--ec-page-text))]">Version Comparison</Dialog.Title>
32
32
  <Dialog.Description className="text-sm text-[rgb(var(--ec-page-text-muted))] mt-1">
33
33
  {messageName}
34
34
  </Dialog.Description>
@@ -51,7 +51,7 @@ export default function VersionHistoryModal({
51
51
  <DiffViewer diffs={diffs} apiAccessEnabled={apiAccessEnabled} />
52
52
  ) : (
53
53
  <div className="flex items-center justify-center h-full">
54
- <p className="text-[rgb(var(--ec-page-text-muted))] text-center">No version history available</p>
54
+ <p className="text-[rgb(var(--ec-page-text-muted))] text-center">No version comparison available</p>
55
55
  </div>
56
56
  )}
57
57
  </div>
@@ -45,9 +45,9 @@ export interface SchemaItem {
45
45
  }
46
46
 
47
47
  export interface VersionDiff {
48
- newerVersion: string;
49
- olderVersion: string;
48
+ fromVersion: string;
49
+ toVersion: string;
50
50
  diffHtml: string;
51
- newerContent: string;
52
- olderContent: string;
51
+ fromContent: string;
52
+ toContent: string;
53
53
  }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Licensed under the EventCatalog Commercial License.
3
+ * See /packages/core/eventcatalog/src/enterprise/LICENSE
4
+ */
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+
9
+ export interface CustomPageRoute {
10
+ /** Route pattern including the configured prefix, e.g. /custom/reports/[id] */
11
+ pattern: string;
12
+ /** File path relative to the custom pages directory */
13
+ file: string;
14
+ type: 'page' | 'endpoint';
15
+ }
16
+
17
+ export const DEFAULT_CUSTOM_PAGES_PREFIX = 'custom';
18
+
19
+ /**
20
+ * Tracks the routable files during dev so route injection re-runs when files
21
+ * are added or removed. Never a route itself.
22
+ */
23
+ export const CUSTOM_PAGES_MANIFEST_FILENAME = '.routes-manifest.json';
24
+
25
+ const CUSTOM_PAGE_EXTENSIONS = /\.(astro|ts|js|mjs|json)$/i;
26
+
27
+ /**
28
+ * Top-level route segments owned by EventCatalog. The custom pages prefix
29
+ * cannot start with any of these — everything else is collision-free because
30
+ * all user routes live under the prefix.
31
+ */
32
+ const RESERVED_PREFIXES = [
33
+ 'api',
34
+ 'api-catalog',
35
+ 'architecture',
36
+ 'auth',
37
+ 'diagrams',
38
+ 'directory',
39
+ 'discover',
40
+ 'docs',
41
+ 'icepanel',
42
+ 'miro',
43
+ 'rss',
44
+ 'schemas',
45
+ 'settings',
46
+ 'studio',
47
+ 'unauthorized',
48
+ 'visualiser',
49
+ '.well-known',
50
+ ];
51
+
52
+ /**
53
+ * Normalizes and validates the configured prefix (e.g. 'custom', 'internal/tools').
54
+ * Throws when the prefix is empty, contains unsafe characters or shadows a core route.
55
+ */
56
+ export const resolveCustomPagesPrefix = (prefix: string | undefined): string => {
57
+ const normalized = (prefix ?? DEFAULT_CUSTOM_PAGES_PREFIX).replace(/^\/+|\/+$/g, '');
58
+
59
+ if (normalized.length === 0) {
60
+ throw new Error('[EventCatalog] pages.prefix cannot be empty.');
61
+ }
62
+
63
+ if (!/^[a-zA-Z0-9-_]+(\/[a-zA-Z0-9-_]+)*$/.test(normalized)) {
64
+ throw new Error(
65
+ `[EventCatalog] pages.prefix "${prefix}" is invalid. Use URL-safe characters (letters, numbers, - and _), optionally separated by "/".`
66
+ );
67
+ }
68
+
69
+ const firstSegment = normalized.split('/')[0].toLowerCase();
70
+ if (RESERVED_PREFIXES.includes(firstSegment)) {
71
+ throw new Error(`[EventCatalog] pages.prefix "${prefix}" is reserved by EventCatalog. Choose a different prefix.`);
72
+ }
73
+
74
+ return normalized;
75
+ };
76
+
77
+ /**
78
+ * Derives an Astro route pattern from a file path relative to the custom pages
79
+ * directory. Dynamic segments ([id], [...slug]) pass through untouched.
80
+ */
81
+ export const deriveRoutePattern = (relativeFilePath: string, prefix: string): string => {
82
+ const withoutExtension = relativeFilePath.replace(CUSTOM_PAGE_EXTENSIONS, '');
83
+ const segments = withoutExtension.split(/[\\/]/).filter(Boolean);
84
+
85
+ if (segments[segments.length - 1] === 'index') {
86
+ segments.pop();
87
+ }
88
+
89
+ return `/${[prefix, ...segments].join('/')}`;
90
+ };
91
+
92
+ const walkDirectory = (directory: string, relativeTo: string): string[] => {
93
+ const files: string[] = [];
94
+
95
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
96
+ const fullPath = path.join(directory, entry.name);
97
+ if (entry.isDirectory()) {
98
+ files.push(...walkDirectory(fullPath, relativeTo));
99
+ } else if (entry.isFile()) {
100
+ files.push(path.relative(relativeTo, fullPath));
101
+ }
102
+ }
103
+
104
+ return files;
105
+ };
106
+
107
+ /**
108
+ * Lists the routable code files in the custom pages directory (sorted, relative paths).
109
+ * Underscore-prefixed files and directories follow the Astro convention: they are
110
+ * never routable (partials, helpers, colocated components).
111
+ */
112
+ export const listCustomPageFiles = (customPagesDir: string): string[] => {
113
+ if (!fs.existsSync(customPagesDir)) return [];
114
+
115
+ return walkDirectory(customPagesDir, customPagesDir)
116
+ .filter((file) => CUSTOM_PAGE_EXTENSIONS.test(file))
117
+ .filter((file) => file !== CUSTOM_PAGES_MANIFEST_FILENAME)
118
+ .filter((file) => !file.split(path.sep).some((segment) => segment.startsWith('_')))
119
+ .sort();
120
+ };
121
+
122
+ /**
123
+ * Reads the custom pages directory and returns the routes to inject.
124
+ */
125
+ export const getCustomPageRoutes = (customPagesDir: string, prefix: string): CustomPageRoute[] => {
126
+ const routes: CustomPageRoute[] = [];
127
+
128
+ for (const file of listCustomPageFiles(customPagesDir)) {
129
+ // pages/homepage.astro is the custom landing page, rendered by src/pages/index.astro
130
+ if (file === 'homepage.astro') continue;
131
+
132
+ routes.push({
133
+ pattern: deriveRoutePattern(file, prefix),
134
+ file,
135
+ type: file.toLowerCase().endsWith('.astro') ? 'page' : 'endpoint',
136
+ });
137
+ }
138
+
139
+ return routes;
140
+ };
141
+
142
+ export const isApiRoute = (route: CustomPageRoute): boolean => route.file.split(path.sep)[0] === 'api';
@@ -53,6 +53,9 @@ export const isEventCatalogUpgradeEnabled = () => !isEventCatalogStarterEnabled(
53
53
  // Custom landing pages are always enabled now.
54
54
  export const isCustomLandingPageEnabled = () => true;
55
55
 
56
+ // User-defined pages and API routes (pages/*.astro, pages/api/*.ts)
57
+ export const isCustomPagesEnabled = () => isEventCatalogScaleEnabled();
58
+
56
59
  export const isAuthEnabled = () => {
57
60
  const isAuthEnabledInCatalog = config?.auth?.enabled ?? false;
58
61
  const directory = process.env.PROJECT_DIR || process.cwd();
@@ -4,23 +4,80 @@
4
4
  */
5
5
 
6
6
  import type { AstroIntegration } from 'astro';
7
+ import fs from 'node:fs';
7
8
  import path from 'path';
8
9
  import config from '../../../eventcatalog.config.js';
9
10
  import {
10
11
  isEventCatalogChatEnabled,
11
12
  isAuthEnabled,
12
13
  isEventCatalogScaleEnabled,
13
- isEventCatalogStarterEnabled,
14
14
  isEventCatalogMCPEnabled,
15
15
  isEventCatalogMCPAuthEnabled,
16
16
  isFullCatalogAPIEnabled,
17
17
  isDevMode,
18
18
  isIntegrationsEnabled,
19
19
  isCustomDocsEnabled,
20
+ isCustomPagesEnabled,
20
21
  isSSR,
21
22
  } from '../../utils/feature';
23
+ import {
24
+ CUSTOM_PAGES_MANIFEST_FILENAME,
25
+ getCustomPageRoutes,
26
+ isApiRoute,
27
+ listCustomPageFiles,
28
+ resolveCustomPagesPrefix,
29
+ } from '../custom-pages/routes';
22
30
 
23
31
  const catalogDirectory = process.env.CATALOG_DIR || process.cwd();
32
+ const customPagesDirectory = path.join(catalogDirectory, 'src/custom-pages');
33
+
34
+ // Routes are injected at astro:config:setup, so adding or removing a custom page
35
+ // file needs a full Astro restart before its route exists. This manifest is
36
+ // registered via addWatchFile — rewriting it with a changed file list triggers
37
+ // that restart (a Vite-level server.restart() does NOT re-run route injection).
38
+ const customPagesManifest = path.join(customPagesDirectory, CUSTOM_PAGES_MANIFEST_FILENAME);
39
+
40
+ const writeCustomPagesManifest = () => {
41
+ const content = JSON.stringify(listCustomPageFiles(customPagesDirectory));
42
+
43
+ try {
44
+ if (fs.existsSync(customPagesManifest) && fs.readFileSync(customPagesManifest, 'utf-8') === content) {
45
+ return;
46
+ }
47
+
48
+ fs.mkdirSync(customPagesDirectory, { recursive: true });
49
+ fs.writeFileSync(customPagesManifest, content);
50
+ } catch (error: any) {
51
+ console.warn(`[EventCatalog] Could not update the custom pages manifest: ${error.message}`);
52
+ }
53
+ };
54
+
55
+ const configureCustomPages = (params: {
56
+ command: 'dev' | 'build' | 'preview' | 'sync';
57
+ injectRoute: (route: { pattern: string; entrypoint: string }) => void;
58
+ }) => {
59
+ const prefix = resolveCustomPagesPrefix(config.pages?.prefix);
60
+ const routes = getCustomPageRoutes(customPagesDirectory, prefix);
61
+
62
+ const apiRoutes = routes.filter(isApiRoute);
63
+ if (apiRoutes.length > 0 && !isSSR()) {
64
+ const message = `Custom API routes (${apiRoutes.map((route) => `pages/${route.file}`).join(', ')}) require EventCatalog to run in server mode. Set output: 'server' in your eventcatalog.config.js or remove the pages/api directory.`;
65
+
66
+ // The dev server serves endpoints dynamically regardless of output mode,
67
+ // so only fail the build — but warn early during development.
68
+ if (params.command === 'build') {
69
+ throw new Error(`[EventCatalog] ${message}`);
70
+ }
71
+ console.warn(`[EventCatalog] ${message}`);
72
+ }
73
+
74
+ for (const route of routes) {
75
+ params.injectRoute({
76
+ pattern: route.pattern,
77
+ entrypoint: path.join(customPagesDirectory, route.file),
78
+ });
79
+ }
80
+ };
24
81
 
25
82
  const configureAuthentication = (params: {
26
83
  injectRoute: (route: { pattern: string; entrypoint: string }) => void;
@@ -148,6 +205,18 @@ export default function eventCatalogIntegration(): AstroIntegration {
148
205
  });
149
206
  }
150
207
 
208
+ // User-defined pages and API routes (Scale plan)
209
+ if (isCustomPagesEnabled()) {
210
+ configureCustomPages(params);
211
+ } else if (listCustomPageFiles(customPagesDirectory).length > 0) {
212
+ console.warn('[EventCatalog] Custom pages require the Scale plan. The routes for your pages will not be served.');
213
+ }
214
+
215
+ // Keep routes in sync during dev — a manifest change restarts the dev
216
+ // server so injectRoute runs again with the new file list
217
+ writeCustomPagesManifest();
218
+ params.addWatchFile(customPagesManifest);
219
+
151
220
  // Warn if integrations are configured without Scale plan
152
221
  if (config.integrations && !isIntegrationsEnabled()) {
153
222
  console.warn('[EventCatalog] Integrations require the Scale plan. Analytics integrations will not be loaded.');
@@ -165,6 +234,20 @@ export default function eventCatalogIntegration(): AstroIntegration {
165
234
  });
166
235
  }
167
236
  },
237
+ 'astro:server:setup': ({ server }) => {
238
+ // When custom page files appear or disappear, refresh the manifest.
239
+ // A content change restarts the dev server (via addWatchFile above),
240
+ // which re-runs route injection. Unchanged content writes nothing,
241
+ // so events from the watcher's initial scan are no-ops.
242
+ const onCustomPageChange = (file: string) => {
243
+ if (!path.resolve(file).startsWith(customPagesDirectory + path.sep)) return;
244
+ if (!/\.(astro|ts|js|mjs)$/i.test(file)) return;
245
+ writeCustomPagesManifest();
246
+ };
247
+
248
+ server.watcher.on('add', onCustomPageChange);
249
+ server.watcher.on('unlink', onCustomPageChange);
250
+ },
168
251
  },
169
252
  };
170
253
  }
@@ -21,7 +21,7 @@ const props = Astro.props;
21
21
  const pathToUserDefinedLandingPage = path.join(process.env.PROJECT_DIR || '', 'pages/homepage.astro');
22
22
 
23
23
  if (existsSync(pathToUserDefinedLandingPage) && isCustomLandingPageEnabled()) {
24
- const customPages = import.meta.glob('/**/homepage.astro', { eager: true });
24
+ const customPages = import.meta.glob('/src/custom-pages/homepage.astro', { eager: true });
25
25
  // @ts-ignore
26
26
  CustomContent = Object.values(customPages)[0]?.default || null;
27
27
 
@@ -0,0 +1,25 @@
1
+ ---
2
+ /**
3
+ * Public layout shell for user-defined custom pages, imported by catalog
4
+ * authors as `@catalog/layouts/Layout.astro`.
5
+ *
6
+ * The props here are a documented, stable interface — internal layouts can
7
+ * change freely, this contract cannot.
8
+ */
9
+ interface Props {
10
+ title: string;
11
+ description?: string;
12
+ /** Render the resource sidebar (domains, services, messages). Defaults to true. */
13
+ sidebar?: boolean;
14
+ /** Show the EventCatalog header (search, navigation). Defaults to true. */
15
+ showHeader?: boolean;
16
+ }
17
+
18
+ import VerticalSideBarLayout from '@layouts/VerticalSideBarLayout.astro';
19
+
20
+ const { title, description, sidebar = true, showHeader = true } = Astro.props;
21
+ ---
22
+
23
+ <VerticalSideBarLayout title={title} description={description} showHeader={showHeader} showNestedSideBar={sidebar}>
24
+ <slot />
25
+ </VerticalSideBarLayout>
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Public utilities for user-defined custom pages, imported by catalog
3
+ * authors as `@catalog/utils`.
4
+ *
5
+ * These re-exports are a documented, stable interface — internal utils can
6
+ * change freely, this contract cannot. Getters return hydrated, cached
7
+ * collection entries (pass { getAllVersions: false } for latest versions only).
8
+ */
9
+ export { getDomains } from '@utils/collections/domains';
10
+ export { getServices } from '@utils/collections/services';
11
+ export { getSystems } from '@utils/collections/systems';
12
+ export { getEvents } from '@utils/collections/events';
13
+ export { getCommands } from '@utils/collections/commands';
14
+ export { getQueries } from '@utils/collections/queries';
15
+ export { getFlows } from '@utils/collections/flows';
16
+ export { getChannels } from '@utils/collections/channels';
17
+ export { getEntities } from '@utils/collections/entities';
18
+ export { getAgents } from '@utils/collections/agents';
19
+ export { getContainers } from '@utils/collections/containers';
20
+ export { getDataProducts } from '@utils/collections/data-products';
21
+ export { getAdrs } from '@utils/collections/adrs';
22
+ export { getTeams } from '@utils/collections/teams';
23
+ export { getUsers } from '@utils/collections/users';
24
+
25
+ // Resolve a specific version (semver) or the latest version of items in a collection
26
+ export { getItemsFromCollectionByIdAndSemverOrLatest } from '@utils/collections/util';
@@ -25,6 +25,7 @@ export {
25
25
  isEventCatalogChatEnabled,
26
26
  isEventCatalogUpgradeEnabled,
27
27
  isCustomLandingPageEnabled,
28
+ isCustomPagesEnabled,
28
29
  isAuthEnabled,
29
30
  isCustomStylesEnabled,
30
31
  isEventCatalogMCPEnabled,