@eventcatalog/core 4.10.10 → 4.10.12
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 +3 -3
- package/dist/{chunk-Y6VX2CGL.js → chunk-7URQ754Q.js} +1 -1
- package/dist/{chunk-PR77FB54.js → chunk-A53VCEEA.js} +1 -1
- package/dist/{chunk-K45ABG6N.js → chunk-S4LLGOO4.js} +1 -1
- package/dist/{chunk-U6R6BFJ5.js → chunk-VFSEVDCQ.js} +1 -1
- package/dist/{chunk-QXANW76J.js → chunk-Y5XU3YLT.js} +1 -1
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +1 -1
- package/dist/eventcatalog.config.d.cts +9 -0
- package/dist/eventcatalog.config.d.ts +9 -0
- package/dist/eventcatalog.js +5 -5
- 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/astro.config.mjs +2 -0
- package/eventcatalog/src/components/Grids/DomainGrid.tsx +20 -12
- package/eventcatalog/src/components/Grids/message-link.spec.ts +59 -0
- package/eventcatalog/src/components/Grids/message-link.ts +38 -0
- package/eventcatalog/src/components/MDX/ResourceRef/ResourceRef.astro +22 -40
- package/eventcatalog/src/content.config.ts +1 -1
- package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.spec.ts +88 -0
- package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.ts +19 -8
- package/eventcatalog/src/plugins/link-validation.ts +42 -0
- package/eventcatalog/src/utils/collections/domains.ts +1 -80
- package/eventcatalog/src/utils/collections/glob-loader.spec.ts +69 -2
- package/eventcatalog/src/utils/collections/glob-loader.ts +5 -3
- package/eventcatalog/src/utils/collections/hydrate-services.ts +51 -0
- package/eventcatalog/src/utils/collections/schema-loader.ts +1 -1
- package/eventcatalog/src/utils/collections/systems.ts +17 -7
- package/eventcatalog/src/utils/link-validation.ts +224 -0
- package/eventcatalog/src/utils/resource-reference-links.ts +29 -0
- package/package.json +4 -3
|
@@ -3,11 +3,27 @@ import { HybridPage } from '@utils/page-loaders/hybrid-page';
|
|
|
3
3
|
import type { PageTypes } from '@types';
|
|
4
4
|
import { pageDataLoader } from '@utils/page-loaders/page-data-loader';
|
|
5
5
|
import { getDomains } from '@utils/collections/domains';
|
|
6
|
-
import { getServices } from '@utils/collections/services';
|
|
7
6
|
import { getSystems } from '@utils/collections/systems';
|
|
8
7
|
|
|
9
8
|
const architecturePageTypes: PageTypes[] = ['services', 'domains', 'systems'];
|
|
10
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Architecture grids render service sends/receives as docs links, so domains and
|
|
12
|
+
* systems must hydrate those messages (collection + name). `pageDataLoader`
|
|
13
|
+
* uses the cheaper unenriched path used by docs/sidebar.
|
|
14
|
+
*/
|
|
15
|
+
export const loadArchitectureItems = (type: PageTypes) => {
|
|
16
|
+
if (type === 'domains') {
|
|
17
|
+
return getDomains({ enrichServices: true });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (type === 'systems') {
|
|
21
|
+
return getSystems({ enrichServices: true });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return pageDataLoader[type as PageTypes]();
|
|
25
|
+
};
|
|
26
|
+
|
|
11
27
|
/**
|
|
12
28
|
* Documentation page class for all collection types with versioning
|
|
13
29
|
*/
|
|
@@ -17,11 +33,7 @@ export class Page extends HybridPage {
|
|
|
17
33
|
return [];
|
|
18
34
|
}
|
|
19
35
|
|
|
20
|
-
const
|
|
21
|
-
const services = await getServices();
|
|
22
|
-
const systems = await getSystems();
|
|
23
|
-
|
|
24
|
-
const pageData = [services, domains, systems];
|
|
36
|
+
const pageData = await Promise.all(architecturePageTypes.map((type) => loadArchitectureItems(type)));
|
|
25
37
|
|
|
26
38
|
return pageData.flatMap((items, index) =>
|
|
27
39
|
items.map((item) => ({
|
|
@@ -47,8 +59,7 @@ export class Page extends HybridPage {
|
|
|
47
59
|
return null;
|
|
48
60
|
}
|
|
49
61
|
|
|
50
|
-
|
|
51
|
-
const items = await pageDataLoader[type as PageTypes]();
|
|
62
|
+
const items = await loadArchitectureItems(type as PageTypes);
|
|
52
63
|
|
|
53
64
|
// Find the specific item by id and version
|
|
54
65
|
const item = items.find((i) => i.data.id === id && i.data.version === version);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { AstroConfig, AstroIntegration } from 'astro';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { formatBrokenLinks, validateBuiltLinks, type LinkValidationOptions } from '../utils/link-validation';
|
|
4
|
+
|
|
5
|
+
export const linkValidation = (options: LinkValidationOptions | false = {}): AstroIntegration => {
|
|
6
|
+
let config: AstroConfig;
|
|
7
|
+
let serverOutput = false;
|
|
8
|
+
return {
|
|
9
|
+
name: 'eventcatalog:link-validation',
|
|
10
|
+
hooks: {
|
|
11
|
+
'astro:config:done': ({ config: resolvedConfig, buildOutput }) => {
|
|
12
|
+
config = resolvedConfig;
|
|
13
|
+
serverOutput = buildOutput === 'server';
|
|
14
|
+
},
|
|
15
|
+
'astro:build:done': async ({ dir, logger }) => {
|
|
16
|
+
if (options === false || (options.onBrokenLinks === 'ignore' && options.onBrokenAnchors === 'ignore')) return;
|
|
17
|
+
if (serverOutput) {
|
|
18
|
+
logger.info('Link validation skipped: only static catalog builds are supported.');
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const start = performance.now();
|
|
22
|
+
const { pages, diagnostics } = await validateBuiltLinks({
|
|
23
|
+
...options,
|
|
24
|
+
outDir: fileURLToPath(dir),
|
|
25
|
+
base: config.base,
|
|
26
|
+
site: config.site,
|
|
27
|
+
format: config.build.format,
|
|
28
|
+
trailingSlash: config.trailingSlash,
|
|
29
|
+
});
|
|
30
|
+
const errors = diagnostics.filter((diagnostic) =>
|
|
31
|
+
diagnostic.kind === 'link' ? options.onBrokenLinks === 'error' : options.onBrokenAnchors === 'error'
|
|
32
|
+
);
|
|
33
|
+
const warnings = diagnostics.filter((diagnostic) =>
|
|
34
|
+
diagnostic.kind === 'link' ? options.onBrokenLinks !== 'error' : options.onBrokenAnchors !== 'error'
|
|
35
|
+
);
|
|
36
|
+
if (warnings.length > 0) logger.warn(formatBrokenLinks(warnings));
|
|
37
|
+
if (errors.length > 0) throw new Error(`Link validation failed.\n${formatBrokenLinks(errors)}`);
|
|
38
|
+
logger.info(`Checked links in ${pages} HTML page(s) in ${((performance.now() - start) / 1000).toFixed(2)}s.`);
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
};
|
|
@@ -4,6 +4,7 @@ import path from 'path';
|
|
|
4
4
|
import type { CollectionMessageTypes } from '@types';
|
|
5
5
|
import type { Agent, Service } from './types';
|
|
6
6
|
import { createVersionedMap, findInMap, processSpecifications } from '@utils/collections/util';
|
|
7
|
+
import { hydrateAgents, hydrateServices } from '@utils/collections/hydrate-services';
|
|
7
8
|
|
|
8
9
|
const CACHE_ENABLED = process.env.DISABLE_EVENTCATALOG_CACHE !== 'true';
|
|
9
10
|
|
|
@@ -19,86 +20,6 @@ interface Props {
|
|
|
19
20
|
// Simple in-memory cache variable
|
|
20
21
|
let memoryCache: Record<string, Domain[]> = {};
|
|
21
22
|
|
|
22
|
-
// Helper to hydrate services
|
|
23
|
-
const hydrateServices = (
|
|
24
|
-
servicesList: any[],
|
|
25
|
-
serviceMap: Map<string, any[]>,
|
|
26
|
-
messageMap: Map<string, any[]>,
|
|
27
|
-
containerMap: Map<string, any[]>
|
|
28
|
-
) => {
|
|
29
|
-
return servicesList
|
|
30
|
-
.map((service: { id: string; version: string | undefined }) => findInMap(serviceMap, service.id, service.version))
|
|
31
|
-
.filter((s) => !!s)
|
|
32
|
-
.map((service) => {
|
|
33
|
-
// Hydrate service messages and containers
|
|
34
|
-
const sends = (service.data.sends || [])
|
|
35
|
-
.map((msg: any) => findInMap(messageMap, msg.id, msg.version))
|
|
36
|
-
.filter((m: any) => !!m);
|
|
37
|
-
|
|
38
|
-
const receives = (service.data.receives || [])
|
|
39
|
-
.map((msg: any) => findInMap(messageMap, msg.id, msg.version))
|
|
40
|
-
.filter((m: any) => !!m);
|
|
41
|
-
|
|
42
|
-
const readsFrom = (service.data.readsFrom || [])
|
|
43
|
-
.map((c: any) => findInMap(containerMap, c.id, c.version))
|
|
44
|
-
.filter((c: any) => !!c);
|
|
45
|
-
|
|
46
|
-
const writesTo = (service.data.writesTo || [])
|
|
47
|
-
.map((c: any) => findInMap(containerMap, c.id, c.version))
|
|
48
|
-
.filter((c: any) => !!c);
|
|
49
|
-
|
|
50
|
-
return {
|
|
51
|
-
...service,
|
|
52
|
-
data: {
|
|
53
|
-
...service.data,
|
|
54
|
-
sends: sends as any,
|
|
55
|
-
receives: receives as any,
|
|
56
|
-
readsFrom: readsFrom as any,
|
|
57
|
-
writesTo: writesTo as any,
|
|
58
|
-
},
|
|
59
|
-
};
|
|
60
|
-
});
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
const hydrateAgents = (
|
|
64
|
-
agentsList: any[],
|
|
65
|
-
agentMap: Map<string, any[]>,
|
|
66
|
-
messageMap: Map<string, any[]>,
|
|
67
|
-
containerMap: Map<string, any[]>
|
|
68
|
-
) => {
|
|
69
|
-
return agentsList
|
|
70
|
-
.map((agent: { id: string; version: string | undefined }) => findInMap(agentMap, agent.id, agent.version))
|
|
71
|
-
.filter((a) => !!a)
|
|
72
|
-
.map((agent) => {
|
|
73
|
-
const sends = (agent.data.sends || [])
|
|
74
|
-
.map((msg: any) => findInMap(messageMap, msg.id, msg.version))
|
|
75
|
-
.filter((m: any) => !!m);
|
|
76
|
-
|
|
77
|
-
const receives = (agent.data.receives || [])
|
|
78
|
-
.map((msg: any) => findInMap(messageMap, msg.id, msg.version))
|
|
79
|
-
.filter((m: any) => !!m);
|
|
80
|
-
|
|
81
|
-
const readsFrom = (agent.data.readsFrom || [])
|
|
82
|
-
.map((c: any) => findInMap(containerMap, c.id, c.version))
|
|
83
|
-
.filter((c: any) => !!c);
|
|
84
|
-
|
|
85
|
-
const writesTo = (agent.data.writesTo || [])
|
|
86
|
-
.map((c: any) => findInMap(containerMap, c.id, c.version))
|
|
87
|
-
.filter((c: any) => !!c);
|
|
88
|
-
|
|
89
|
-
return {
|
|
90
|
-
...agent,
|
|
91
|
-
data: {
|
|
92
|
-
...agent.data,
|
|
93
|
-
sends: sends as any,
|
|
94
|
-
receives: receives as any,
|
|
95
|
-
readsFrom: readsFrom as any,
|
|
96
|
-
writesTo: writesTo as any,
|
|
97
|
-
},
|
|
98
|
-
};
|
|
99
|
-
});
|
|
100
|
-
};
|
|
101
|
-
|
|
102
23
|
// --- MAIN FUNCTION ---
|
|
103
24
|
|
|
104
25
|
export const getDomains = async ({
|
|
@@ -1,6 +1,73 @@
|
|
|
1
1
|
import picomatch from 'picomatch';
|
|
2
|
-
import { describe, expect, it } from 'vitest';
|
|
3
|
-
import {
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { mkdtemp, mkdir, writeFile, symlink, rm } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { pathToFileURL } from 'node:url';
|
|
7
|
+
import { globWithSafeWatcher, withFederatedContent, withIgnoredBuildArtifacts } from './glob-loader';
|
|
8
|
+
|
|
9
|
+
describe('catalog discovery', () => {
|
|
10
|
+
const directories: string[] = [];
|
|
11
|
+
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
vi.unstubAllEnvs();
|
|
14
|
+
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it.each([undefined, 'false', 'true'])('excludes dependency catalogs when IGNORE_BUILD_ARTIFACTS is %s', async (flag) => {
|
|
18
|
+
vi.stubEnv('IGNORE_BUILD_ARTIFACTS', flag);
|
|
19
|
+
const root = await mkdtemp(path.join(tmpdir(), 'catalog-discovery-'));
|
|
20
|
+
directories.push(root);
|
|
21
|
+
const catalog = path.join(root, 'catalog');
|
|
22
|
+
const resources = [
|
|
23
|
+
'events/OrderConfirmed/index.md',
|
|
24
|
+
'domains/Orders/services/Inventory/events/Adjusted/versioned/1.0.0/index.mdx',
|
|
25
|
+
'federated/orders/events/OrderConfirmed/index.mdx',
|
|
26
|
+
];
|
|
27
|
+
const dependencies = [
|
|
28
|
+
'node_modules/core/src/__tests__/events/OrderConfirmed/index.md',
|
|
29
|
+
'node_modules/core/node_modules/sdk/events/OrderConfirmed/index.mdx',
|
|
30
|
+
'federated/orders/node_modules/sdk/events/OrderConfirmed/index.md',
|
|
31
|
+
];
|
|
32
|
+
for (const entry of [...resources, ...dependencies]) {
|
|
33
|
+
const file = path.join(catalog, entry);
|
|
34
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
35
|
+
await writeFile(file, entry);
|
|
36
|
+
}
|
|
37
|
+
// Workspace dependencies are often symlinked outside the catalog.
|
|
38
|
+
const linkedPackage = path.join(root, 'linked-package');
|
|
39
|
+
await mkdir(path.join(linkedPackage, 'events/OrderConfirmed'), { recursive: true });
|
|
40
|
+
await writeFile(path.join(linkedPackage, 'events/OrderConfirmed/index.md'), 'dependency');
|
|
41
|
+
await symlink(linkedPackage, path.join(catalog, 'node_modules/linked'));
|
|
42
|
+
|
|
43
|
+
const loaded = new Map();
|
|
44
|
+
const logger = { warn: vi.fn(), error: vi.fn() };
|
|
45
|
+
const base = pathToFileURL(`${catalog}/`);
|
|
46
|
+
const loader = globWithSafeWatcher({
|
|
47
|
+
pattern: withIgnoredBuildArtifacts('**/events/**/index.(md|mdx)'),
|
|
48
|
+
base,
|
|
49
|
+
generateId: ({ entry }) => entry,
|
|
50
|
+
});
|
|
51
|
+
await loader.load({
|
|
52
|
+
config: { root: base, srcDir: new URL('src/', base) },
|
|
53
|
+
collection: 'events',
|
|
54
|
+
logger,
|
|
55
|
+
store: {
|
|
56
|
+
keys: () => loaded.keys(),
|
|
57
|
+
get: (id: string) => loaded.get(id),
|
|
58
|
+
set: (entry: { id: string }) => loaded.set(entry.id, entry),
|
|
59
|
+
delete: (id: string) => loaded.delete(id),
|
|
60
|
+
},
|
|
61
|
+
parseData: async ({ data }: { data: unknown }) => data,
|
|
62
|
+
generateDigest: (contents: string) => contents,
|
|
63
|
+
entryTypes: new Map(['.md', '.mdx'].map((ext) => [ext, { getEntryInfo: () => ({ data: {}, body: '' }) }])),
|
|
64
|
+
} as unknown as Parameters<typeof loader.load>[0]);
|
|
65
|
+
|
|
66
|
+
expect([...loaded.keys()].sort()).toEqual(resources.sort());
|
|
67
|
+
expect(logger.warn).not.toHaveBeenCalled();
|
|
68
|
+
expect(logger.error).not.toHaveBeenCalled();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
4
71
|
|
|
5
72
|
describe('withFederatedContent', () => {
|
|
6
73
|
it('loads root catalog content from every federated source directory', () => {
|
|
@@ -6,11 +6,13 @@ import { fileURLToPath } from 'url';
|
|
|
6
6
|
export type GlobOptions = Parameters<typeof glob>[0];
|
|
7
7
|
|
|
8
8
|
export const withIgnoredBuildArtifacts = (patterns: string | string[]) => {
|
|
9
|
+
// Dependencies can contain entire example catalogs, including duplicate resource IDs.
|
|
10
|
+
// Exclude them in every mode, including astro check and the development watcher.
|
|
11
|
+
const ignoredArtifacts = ['!**/node_modules/**'];
|
|
9
12
|
if (process.env.IGNORE_BUILD_ARTIFACTS === 'true') {
|
|
10
|
-
|
|
11
|
-
return Array.isArray(patterns) ? [...patterns, ...ignoredArtifacts] : [patterns, ...ignoredArtifacts];
|
|
13
|
+
ignoredArtifacts.push('!dist/**', '!**/dist/**');
|
|
12
14
|
}
|
|
13
|
-
return patterns;
|
|
15
|
+
return [...(Array.isArray(patterns) ? patterns : [patterns]), ...ignoredArtifacts];
|
|
14
16
|
};
|
|
15
17
|
|
|
16
18
|
const toPatterns = (patterns: string | string[]) => (Array.isArray(patterns) ? patterns : [patterns]);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { findInMap } from '@utils/collections/util';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve service/agent pointers to collection entries and hydrate their
|
|
5
|
+
* sends/receives/readsFrom/writesTo relationships.
|
|
6
|
+
*/
|
|
7
|
+
export const hydrateServices = (
|
|
8
|
+
servicesList: any[],
|
|
9
|
+
serviceMap: Map<string, any[]>,
|
|
10
|
+
messageMap: Map<string, any[]>,
|
|
11
|
+
containerMap: Map<string, any[]>
|
|
12
|
+
) => {
|
|
13
|
+
return servicesList
|
|
14
|
+
.map((service: { id: string; version: string | undefined }) => findInMap(serviceMap, service.id, service.version))
|
|
15
|
+
.filter((s) => !!s)
|
|
16
|
+
.map((service) => {
|
|
17
|
+
const sends = (service.data.sends || [])
|
|
18
|
+
.map((msg: any) => findInMap(messageMap, msg.id, msg.version))
|
|
19
|
+
.filter((m: any) => !!m);
|
|
20
|
+
|
|
21
|
+
const receives = (service.data.receives || [])
|
|
22
|
+
.map((msg: any) => findInMap(messageMap, msg.id, msg.version))
|
|
23
|
+
.filter((m: any) => !!m);
|
|
24
|
+
|
|
25
|
+
const readsFrom = (service.data.readsFrom || [])
|
|
26
|
+
.map((c: any) => findInMap(containerMap, c.id, c.version))
|
|
27
|
+
.filter((c: any) => !!c);
|
|
28
|
+
|
|
29
|
+
const writesTo = (service.data.writesTo || [])
|
|
30
|
+
.map((c: any) => findInMap(containerMap, c.id, c.version))
|
|
31
|
+
.filter((c: any) => !!c);
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
...service,
|
|
35
|
+
data: {
|
|
36
|
+
...service.data,
|
|
37
|
+
sends: sends as any,
|
|
38
|
+
receives: receives as any,
|
|
39
|
+
readsFrom: readsFrom as any,
|
|
40
|
+
writesTo: writesTo as any,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const hydrateAgents = (
|
|
47
|
+
agentsList: any[],
|
|
48
|
+
agentMap: Map<string, any[]>,
|
|
49
|
+
messageMap: Map<string, any[]>,
|
|
50
|
+
containerMap: Map<string, any[]>
|
|
51
|
+
) => hydrateServices(agentsList, agentMap, messageMap, containerMap);
|
|
@@ -450,7 +450,7 @@ const loadMessageSchemaResources = async ({ pattern, base }: SchemaLoaderOptions
|
|
|
450
450
|
cwd: base,
|
|
451
451
|
absolute: true,
|
|
452
452
|
nodir: true,
|
|
453
|
-
ignore: ['dist/**', '**/dist/**'],
|
|
453
|
+
ignore: ['dist/**', '**/dist/**', '**/node_modules/**'],
|
|
454
454
|
});
|
|
455
455
|
|
|
456
456
|
const schemas = await Promise.all(
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getCollection } from 'astro:content';
|
|
2
2
|
import type { CollectionEntry } from 'astro:content';
|
|
3
|
+
import { hydrateServices } from '@utils/collections/hydrate-services';
|
|
3
4
|
import { createVersionedMap, findInMap } from './util';
|
|
4
5
|
|
|
5
6
|
const CACHE_ENABLED = process.env.DISABLE_EVENTCATALOG_CACHE !== 'true';
|
|
@@ -7,24 +8,28 @@ export type System = CollectionEntry<'systems'>;
|
|
|
7
8
|
|
|
8
9
|
interface Props {
|
|
9
10
|
getAllVersions?: boolean;
|
|
11
|
+
enrichServices?: boolean;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
// cache for build time
|
|
13
15
|
let memoryCache: Record<string, System[]> = {};
|
|
14
16
|
|
|
15
|
-
export const getSystems = async ({ getAllVersions = true }: Props = {}): Promise<System[]> => {
|
|
16
|
-
const cacheKey = getAllVersions ? 'allVersions' : 'currentVersions'
|
|
17
|
+
export const getSystems = async ({ getAllVersions = true, enrichServices = false }: Props = {}): Promise<System[]> => {
|
|
18
|
+
const cacheKey = `${getAllVersions ? 'allVersions' : 'currentVersions'}-${enrichServices ? 'enriched' : 'simple'}`;
|
|
17
19
|
|
|
18
20
|
if (memoryCache[cacheKey] && memoryCache[cacheKey].length > 0 && CACHE_ENABLED) {
|
|
19
21
|
return memoryCache[cacheKey];
|
|
20
22
|
}
|
|
21
23
|
|
|
22
|
-
const [allSystems, allServices, allFlows, allEntities, allContainers] = await Promise.all([
|
|
24
|
+
const [allSystems, allServices, allFlows, allEntities, allContainers, allEvents, allCommands, allQueries] = await Promise.all([
|
|
23
25
|
getCollection('systems'),
|
|
24
26
|
getCollection('services'),
|
|
25
27
|
getCollection('flows'),
|
|
26
28
|
getCollection('entities'),
|
|
27
29
|
getCollection('containers'),
|
|
30
|
+
enrichServices ? getCollection('events') : Promise.resolve([]),
|
|
31
|
+
enrichServices ? getCollection('commands') : Promise.resolve([]),
|
|
32
|
+
enrichServices ? getCollection('queries') : Promise.resolve([]),
|
|
28
33
|
]);
|
|
29
34
|
|
|
30
35
|
// Build optimized map of id -> versions (sorted latest first)
|
|
@@ -33,6 +38,7 @@ export const getSystems = async ({ getAllVersions = true }: Props = {}): Promise
|
|
|
33
38
|
const flowMap = createVersionedMap(allFlows);
|
|
34
39
|
const entityMap = createVersionedMap(allEntities);
|
|
35
40
|
const containerMap = createVersionedMap(allContainers);
|
|
41
|
+
const messageMap = createVersionedMap([...allEvents, ...allCommands, ...allQueries]);
|
|
36
42
|
|
|
37
43
|
// Filter systems
|
|
38
44
|
const targetSystems = allSystems.filter((system) => {
|
|
@@ -47,10 +53,14 @@ export const getSystems = async ({ getAllVersions = true }: Props = {}): Promise
|
|
|
47
53
|
const latestVersion = systemVersions[0]?.data.version || system.data.version;
|
|
48
54
|
const versions = systemVersions.map((s) => s.data.version);
|
|
49
55
|
|
|
50
|
-
// Resolve service pointers to their full collection entries
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
// Resolve service pointers to their full collection entries.
|
|
57
|
+
// Architecture grids need sends/receives hydrated so command/query links
|
|
58
|
+
// keep the correct collection instead of falling back to events.
|
|
59
|
+
const services = enrichServices
|
|
60
|
+
? hydrateServices(system.data.services || [], serviceMap, messageMap, containerMap)
|
|
61
|
+
: (system.data.services || [])
|
|
62
|
+
.map((service: { id: string; version?: string }) => findInMap(serviceMap, service.id, service.version))
|
|
63
|
+
.filter((s): s is NonNullable<typeof s> => !!s);
|
|
54
64
|
|
|
55
65
|
// Resolve flow pointers to their full collection entries
|
|
56
66
|
const flows = (system.data.flows || [])
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parse, type DefaultTreeAdapterMap } from 'parse5';
|
|
4
|
+
import picomatch from 'picomatch';
|
|
5
|
+
|
|
6
|
+
export type LinkValidationSeverity = 'warn' | 'error' | 'ignore';
|
|
7
|
+
|
|
8
|
+
export interface LinkValidationOptions {
|
|
9
|
+
onBrokenLinks?: LinkValidationSeverity;
|
|
10
|
+
onBrokenAnchors?: LinkValidationSeverity;
|
|
11
|
+
ignore?: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface BrokenLink {
|
|
15
|
+
kind: 'link' | 'anchor';
|
|
16
|
+
source: string;
|
|
17
|
+
destination: string;
|
|
18
|
+
suggestion?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface PageLinks {
|
|
22
|
+
source: string;
|
|
23
|
+
baseHref?: string;
|
|
24
|
+
anchors: Set<string>;
|
|
25
|
+
links: Set<string>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface ValidateLinksOptions extends LinkValidationOptions {
|
|
29
|
+
outDir: string;
|
|
30
|
+
base?: string;
|
|
31
|
+
site?: string;
|
|
32
|
+
format?: 'directory' | 'file' | 'preserve';
|
|
33
|
+
trailingSlash?: 'always' | 'never' | 'ignore';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const decodeUrlPart = (value: string): string => {
|
|
37
|
+
try {
|
|
38
|
+
return decodeURIComponent(value);
|
|
39
|
+
} catch {
|
|
40
|
+
// A literal malformed percent escape can still be an HTML id or filename.
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Parse HTML rather than searching markup with a regex: attributes can contain
|
|
46
|
+
// entities, quoted > characters, or single/unquoted values. Scripts and inert
|
|
47
|
+
// template contents must not be mistaken for rendered links or anchor targets.
|
|
48
|
+
const readPage = (html: string, source: string): PageLinks => {
|
|
49
|
+
const page: PageLinks = { source, anchors: new Set(), links: new Set() };
|
|
50
|
+
const nodes: DefaultTreeAdapterMap['node'][] = [parse(html)];
|
|
51
|
+
while (nodes.length > 0) {
|
|
52
|
+
const node = nodes.pop()!;
|
|
53
|
+
if ('tagName' in node) {
|
|
54
|
+
const attrs = new Map(node.attrs.map((attr) => [attr.name, attr.value]));
|
|
55
|
+
const id = attrs.get('id');
|
|
56
|
+
if (id) page.anchors.add(id);
|
|
57
|
+
if (node.tagName === 'a' && attrs.get('name')) page.anchors.add(attrs.get('name')!);
|
|
58
|
+
const href = attrs.get('href');
|
|
59
|
+
if (href !== undefined) {
|
|
60
|
+
if (node.tagName === 'base' && page.baseHref === undefined) page.baseHref = href;
|
|
61
|
+
if (node.tagName === 'a' || node.tagName === 'area') page.links.add(href);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Reverse the stack so the first <base href> wins in document order.
|
|
65
|
+
if ('childNodes' in node) {
|
|
66
|
+
for (let i = node.childNodes.length - 1; i >= 0; i--) nodes.push(node.childNodes[i]);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return page;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const listFiles = async (root: string, relative = '', files = new Set<string>()): Promise<Set<string>> => {
|
|
73
|
+
for (const entry of await fs.readdir(path.join(root, relative), { withFileTypes: true })) {
|
|
74
|
+
const file = path.posix.join(relative, entry.name);
|
|
75
|
+
if (entry.isDirectory()) await listFiles(root, file, files);
|
|
76
|
+
else if (entry.isFile()) files.add(file);
|
|
77
|
+
}
|
|
78
|
+
return files;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const readNavigationLinks = (value: unknown, links: Set<string>) => {
|
|
82
|
+
if (!value || typeof value !== 'object') return;
|
|
83
|
+
for (const [key, child] of Object.entries(value)) {
|
|
84
|
+
if (key === 'href' && typeof child === 'string') links.add(child);
|
|
85
|
+
else if (child && typeof child === 'object') readNavigationLinks(child, links);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const validateBuiltLinks = async ({
|
|
90
|
+
outDir,
|
|
91
|
+
base = '/',
|
|
92
|
+
site,
|
|
93
|
+
format = 'directory',
|
|
94
|
+
trailingSlash = 'ignore',
|
|
95
|
+
onBrokenLinks = 'warn',
|
|
96
|
+
onBrokenAnchors = 'warn',
|
|
97
|
+
ignore = [],
|
|
98
|
+
}: ValidateLinksOptions): Promise<{ pages: number; diagnostics: BrokenLink[] }> => {
|
|
99
|
+
if (onBrokenLinks === 'ignore' && onBrokenAnchors === 'ignore') return { pages: 0, diagnostics: [] };
|
|
100
|
+
|
|
101
|
+
const origin = site ? new URL(site).origin : 'https://eventcatalog.invalid';
|
|
102
|
+
const prefix = new URL(`/${base.replace(/^\/+|\/+$/g, '')}`, origin).pathname.replace(/\/$/, '');
|
|
103
|
+
const withBase = (route: string) => `${prefix}${route}`;
|
|
104
|
+
const files = await listFiles(outDir);
|
|
105
|
+
const pages = new Map<string, PageLinks>();
|
|
106
|
+
const ignored = ignore.map((pattern) => picomatch(pattern, { dot: true }));
|
|
107
|
+
|
|
108
|
+
for (const file of [...files].sort()) {
|
|
109
|
+
if (!file.endsWith('.html')) continue;
|
|
110
|
+
let route = `/${file}`;
|
|
111
|
+
if (file === 'index.html') route = '/';
|
|
112
|
+
else if (file.endsWith('/index.html')) route = route.slice(0, -'index.html'.length);
|
|
113
|
+
else if (format !== 'directory') route = route.slice(0, -'.html'.length);
|
|
114
|
+
if (route !== '/' && trailingSlash === 'never') route = route.replace(/\/$/, '');
|
|
115
|
+
else if (format === 'file' && trailingSlash === 'always' && !route.endsWith('/')) route += '/';
|
|
116
|
+
// Encode path segments, not slashes, to handle spaces, # and Unicode in filenames.
|
|
117
|
+
const source = withBase(route.split('/').map(encodeURIComponent).join('/'));
|
|
118
|
+
pages.set(file, readPage(await fs.readFile(path.join(outDir, file), 'utf8'), source));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const sources = [...pages.values()];
|
|
122
|
+
// This is also used by client-only navigation, so its links aren't necessarily
|
|
123
|
+
// present as <a> elements in any generated HTML page.
|
|
124
|
+
if (files.has('api/sidebar-data.json')) {
|
|
125
|
+
const links = new Set<string>();
|
|
126
|
+
readNavigationLinks(JSON.parse(await fs.readFile(path.join(outDir, 'api/sidebar-data.json'), 'utf8')), links);
|
|
127
|
+
sources.push({
|
|
128
|
+
source: withBase('/api/sidebar-data.json'),
|
|
129
|
+
baseHref: withBase('/'),
|
|
130
|
+
anchors: new Set(),
|
|
131
|
+
links,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const findFile = (pathname: string): string | undefined => {
|
|
136
|
+
const relative = pathname.replace(/^\/+/, '');
|
|
137
|
+
if (files.has(relative)) return relative;
|
|
138
|
+
const index = path.posix.join(relative, 'index.html');
|
|
139
|
+
if (files.has(index)) return index;
|
|
140
|
+
const html = `${relative.replace(/\/$/, '')}.html`;
|
|
141
|
+
if (format !== 'directory' && files.has(html)) return html;
|
|
142
|
+
return undefined;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const diagnostics: BrokenLink[] = [];
|
|
146
|
+
for (const page of sources) {
|
|
147
|
+
const sourceUrl = new URL(page.source, origin);
|
|
148
|
+
let documentBase = sourceUrl;
|
|
149
|
+
try {
|
|
150
|
+
if (page.baseHref !== undefined) documentBase = new URL(page.baseHref, sourceUrl);
|
|
151
|
+
} catch {
|
|
152
|
+
// Browsers ignore an invalid base URL and use the document URL instead.
|
|
153
|
+
}
|
|
154
|
+
const seen = new Set<string>();
|
|
155
|
+
for (const href of page.links) {
|
|
156
|
+
let target: URL;
|
|
157
|
+
try {
|
|
158
|
+
target = new URL(href, documentBase);
|
|
159
|
+
} catch {
|
|
160
|
+
if (onBrokenLinks !== 'ignore') diagnostics.push({ kind: 'link', source: page.source, destination: href });
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (!['http:', 'https:'].includes(target.protocol) || target.origin !== origin) continue;
|
|
164
|
+
if (prefix && target.pathname !== prefix && !target.pathname.startsWith(`${prefix}/`)) continue;
|
|
165
|
+
const pathname = target.pathname.slice(prefix.length) || '/';
|
|
166
|
+
// Ignore patterns use paths relative to the catalog base, never filesystem paths.
|
|
167
|
+
if (ignored.some((matches) => matches(pathname))) continue;
|
|
168
|
+
const destination = target.pathname + target.hash;
|
|
169
|
+
if (seen.has(destination)) continue;
|
|
170
|
+
seen.add(destination);
|
|
171
|
+
|
|
172
|
+
const decodedPath = decodeUrlPart(pathname);
|
|
173
|
+
const anchor = decodeUrlPart(target.hash.slice(1).split(':~:')[0]);
|
|
174
|
+
const file = findFile(decodedPath);
|
|
175
|
+
if (!file) {
|
|
176
|
+
if (onBrokenLinks === 'ignore') continue;
|
|
177
|
+
// Give a precise suggestion for the common resource-type mixups, but
|
|
178
|
+
// only if the suggested destination actually exists in this build.
|
|
179
|
+
const alternatives = /^\/docs\/(users|teams)\//.test(decodedPath)
|
|
180
|
+
? ['users', 'teams']
|
|
181
|
+
: /^\/docs\/(events|commands|queries)\//.test(decodedPath)
|
|
182
|
+
? ['events', 'commands', 'queries']
|
|
183
|
+
: [];
|
|
184
|
+
const suggestion = alternatives
|
|
185
|
+
.map((collection) => decodedPath.replace(/^\/docs\/[^/]+\//, `/docs/${collection}/`))
|
|
186
|
+
.find((route) => findFile(route));
|
|
187
|
+
diagnostics.push({
|
|
188
|
+
kind: 'link',
|
|
189
|
+
source: page.source,
|
|
190
|
+
destination,
|
|
191
|
+
...(suggestion ? { suggestion: withBase(suggestion) } : {}),
|
|
192
|
+
});
|
|
193
|
+
} else if (onBrokenAnchors !== 'ignore' && anchor && anchor.toLowerCase() !== 'top') {
|
|
194
|
+
const targetPage = pages.get(file);
|
|
195
|
+
// Fragments in PDFs/SVGs and other non-HTML assets have different semantics.
|
|
196
|
+
if (targetPage && !targetPage.anchors.has(anchor)) {
|
|
197
|
+
diagnostics.push({ kind: 'anchor', source: page.source, destination });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
pages: pages.size,
|
|
204
|
+
diagnostics: diagnostics.sort((a, b) => a.destination.localeCompare(b.destination) || a.source.localeCompare(b.source)),
|
|
205
|
+
};
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
export const formatBrokenLinks = (diagnostics: BrokenLink[]): string => {
|
|
209
|
+
const groups = new Map<string, { diagnostic: BrokenLink; sources: Set<string> }>();
|
|
210
|
+
for (const diagnostic of diagnostics) {
|
|
211
|
+
const key = `${diagnostic.kind}:${diagnostic.destination}`;
|
|
212
|
+
const group = groups.get(key) ?? { diagnostic, sources: new Set<string>() };
|
|
213
|
+
group.sources.add(diagnostic.source);
|
|
214
|
+
groups.set(key, group);
|
|
215
|
+
}
|
|
216
|
+
const lines = [`Found ${groups.size} broken link/anchor destination(s) in ${diagnostics.length} page reference(s).`];
|
|
217
|
+
for (const { diagnostic, sources } of groups.values()) {
|
|
218
|
+
lines.push(`\nBroken ${diagnostic.kind}: ${diagnostic.destination}`);
|
|
219
|
+
for (const source of [...sources].slice(0, 5)) lines.push(` From: ${source}`);
|
|
220
|
+
if (sources.size > 5) lines.push(` ...and ${sources.size - 5} more source(s)`);
|
|
221
|
+
if (diagnostic.suggestion) lines.push(` Possible destination: ${diagnostic.suggestion}`);
|
|
222
|
+
}
|
|
223
|
+
return lines.join('\n');
|
|
224
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { getCollection } from 'astro:content';
|
|
2
|
+
import { getItemsFromCollectionByIdAndSemverOrLatest, sortVersioned } from './collections/util';
|
|
3
|
+
import { buildUrl } from './url-builder';
|
|
4
|
+
|
|
5
|
+
export const isVersionedReference = (collection: string) => !['users', 'teams', 'customPages'].includes(collection);
|
|
6
|
+
|
|
7
|
+
export const getResourceReferenceUrl = (collection: string, id: string, version?: string) => {
|
|
8
|
+
if (!isVersionedReference(collection)) return buildUrl(`/docs/${collection}/${id}`);
|
|
9
|
+
return buildUrl(`${collection === 'diagrams' ? '/diagrams' : `/docs/${collection}`}/${id}/${version}`);
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const resolveMessageReference = async (message: { id: string; version?: string }) => {
|
|
13
|
+
for (const collection of ['events', 'commands', 'queries'] as const) {
|
|
14
|
+
const items = await getCollection(collection);
|
|
15
|
+
const matches = getItemsFromCollectionByIdAndSemverOrLatest(items, message.id, message.version);
|
|
16
|
+
const [resource] = sortVersioned(matches, (item) => item.data.version);
|
|
17
|
+
if (resource) return { version: resource.data.version, collection };
|
|
18
|
+
}
|
|
19
|
+
return { version: null, collection: null };
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const resolveOwnerReference = async (owner: string | { id: string }) => {
|
|
23
|
+
const id = typeof owner === 'string' ? owner : owner.id;
|
|
24
|
+
for (const collection of ['users', 'teams'] as const) {
|
|
25
|
+
const items = await getCollection(collection);
|
|
26
|
+
if (items.some((item) => item.data.id === id)) return { id, href: getResourceReferenceUrl(collection, id) };
|
|
27
|
+
}
|
|
28
|
+
return { id, href: null };
|
|
29
|
+
};
|