@eventcatalog/core 4.10.11 → 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-VFRR3M72.js → chunk-7URQ754Q.js} +1 -1
- package/dist/{chunk-3XTFNVGA.js → chunk-A53VCEEA.js} +1 -1
- package/dist/{chunk-N6U5CNT7.js → chunk-S4LLGOO4.js} +1 -1
- package/dist/{chunk-RLGMIZSH.js → chunk-VFSEVDCQ.js} +1 -1
- package/dist/{chunk-TWZKZIRW.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/MDX/ResourceRef/ResourceRef.astro +22 -40
- package/eventcatalog/src/content.config.ts +1 -1
- package/eventcatalog/src/plugins/link-validation.ts +42 -0
- 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/schema-loader.ts +1 -1
- package/eventcatalog/src/utils/link-validation.ts +224 -0
- package/eventcatalog/src/utils/resource-reference-links.ts +29 -0
- package/package.json +5 -4
|
@@ -140,7 +140,7 @@ var verifyRequiredFieldsAreInCatalogConfigFile = async (projectDirectory) => {
|
|
|
140
140
|
var import_os = __toESM(require("os"), 1);
|
|
141
141
|
|
|
142
142
|
// package.json
|
|
143
|
-
var version = "4.10.
|
|
143
|
+
var version = "4.10.12";
|
|
144
144
|
|
|
145
145
|
// src/constants.ts
|
|
146
146
|
var VERSION = version;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
log_build_default
|
|
3
|
-
} from "../chunk-
|
|
4
|
-
import "../chunk-
|
|
3
|
+
} from "../chunk-S4LLGOO4.js";
|
|
4
|
+
import "../chunk-Y5XU3YLT.js";
|
|
5
5
|
import "../chunk-ZAZHAVKB.js";
|
|
6
|
-
import "../chunk-
|
|
6
|
+
import "../chunk-A53VCEEA.js";
|
|
7
7
|
import "../chunk-6QENHZZP.js";
|
|
8
8
|
export {
|
|
9
9
|
log_build_default as default
|
package/dist/constants.cjs
CHANGED
package/dist/constants.js
CHANGED
package/dist/eventcatalog.cjs
CHANGED
|
@@ -144,7 +144,7 @@ var verifyRequiredFieldsAreInCatalogConfigFile = async (projectDirectory) => {
|
|
|
144
144
|
var import_picocolors = __toESM(require("picocolors"), 1);
|
|
145
145
|
|
|
146
146
|
// package.json
|
|
147
|
-
var version = "4.10.
|
|
147
|
+
var version = "4.10.12";
|
|
148
148
|
|
|
149
149
|
// src/constants.ts
|
|
150
150
|
var VERSION = version;
|
|
@@ -242,6 +242,15 @@ interface Config {
|
|
|
242
242
|
host?: string | boolean;
|
|
243
243
|
trailingSlash?: boolean;
|
|
244
244
|
output?: 'server' | 'static';
|
|
245
|
+
/** Validate internal links and anchors after static builds. Set false to disable. */
|
|
246
|
+
linkValidation?: false | {
|
|
247
|
+
/** @default 'warn' */
|
|
248
|
+
onBrokenLinks?: 'warn' | 'error' | 'ignore';
|
|
249
|
+
/** @default 'warn' */
|
|
250
|
+
onBrokenAnchors?: 'warn' | 'error' | 'ignore';
|
|
251
|
+
/** Glob patterns for destination URL paths, relative to the catalog base (e.g. /api/**). */
|
|
252
|
+
ignore?: string[];
|
|
253
|
+
};
|
|
245
254
|
server?: {
|
|
246
255
|
allowedHosts?: string[] | true;
|
|
247
256
|
};
|
|
@@ -242,6 +242,15 @@ interface Config {
|
|
|
242
242
|
host?: string | boolean;
|
|
243
243
|
trailingSlash?: boolean;
|
|
244
244
|
output?: 'server' | 'static';
|
|
245
|
+
/** Validate internal links and anchors after static builds. Set false to disable. */
|
|
246
|
+
linkValidation?: false | {
|
|
247
|
+
/** @default 'warn' */
|
|
248
|
+
onBrokenLinks?: 'warn' | 'error' | 'ignore';
|
|
249
|
+
/** @default 'warn' */
|
|
250
|
+
onBrokenAnchors?: 'warn' | 'error' | 'ignore';
|
|
251
|
+
/** Glob patterns for destination URL paths, relative to the catalog base (e.g. /api/**). */
|
|
252
|
+
ignore?: string[];
|
|
253
|
+
};
|
|
245
254
|
server?: {
|
|
246
255
|
allowedHosts?: string[] | true;
|
|
247
256
|
};
|
package/dist/eventcatalog.js
CHANGED
|
@@ -4,8 +4,8 @@ import {
|
|
|
4
4
|
import "./chunk-CA4U2JP7.js";
|
|
5
5
|
import {
|
|
6
6
|
log_build_default
|
|
7
|
-
} from "./chunk-
|
|
8
|
-
import "./chunk-
|
|
7
|
+
} from "./chunk-S4LLGOO4.js";
|
|
8
|
+
import "./chunk-Y5XU3YLT.js";
|
|
9
9
|
import "./chunk-ZAZHAVKB.js";
|
|
10
10
|
import {
|
|
11
11
|
FederationConflictError,
|
|
@@ -31,10 +31,10 @@ import {
|
|
|
31
31
|
} from "./chunk-B7HCX5HM.js";
|
|
32
32
|
import {
|
|
33
33
|
generate
|
|
34
|
-
} from "./chunk-
|
|
34
|
+
} from "./chunk-7URQ754Q.js";
|
|
35
35
|
import {
|
|
36
36
|
logger
|
|
37
|
-
} from "./chunk-
|
|
37
|
+
} from "./chunk-VFSEVDCQ.js";
|
|
38
38
|
import {
|
|
39
39
|
resolve_catalog_dependencies_default
|
|
40
40
|
} from "./chunk-LHR4G2UO.js";
|
|
@@ -54,7 +54,7 @@ import {
|
|
|
54
54
|
import "./chunk-W5JQON7Z.js";
|
|
55
55
|
import {
|
|
56
56
|
VERSION
|
|
57
|
-
} from "./chunk-
|
|
57
|
+
} from "./chunk-A53VCEEA.js";
|
|
58
58
|
import {
|
|
59
59
|
linkCoreNodeModules,
|
|
60
60
|
resolveInstalledCoreNodeModules
|
package/dist/generate.cjs
CHANGED
|
@@ -108,7 +108,7 @@ var getEventCatalogConfigFile = async (projectDirectory) => {
|
|
|
108
108
|
var import_picocolors = __toESM(require("picocolors"), 1);
|
|
109
109
|
|
|
110
110
|
// package.json
|
|
111
|
-
var version = "4.10.
|
|
111
|
+
var version = "4.10.12";
|
|
112
112
|
|
|
113
113
|
// src/constants.ts
|
|
114
114
|
var VERSION = version;
|
package/dist/generate.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
generate
|
|
3
|
-
} from "./chunk-
|
|
4
|
-
import "./chunk-
|
|
5
|
-
import "./chunk-
|
|
3
|
+
} from "./chunk-7URQ754Q.js";
|
|
4
|
+
import "./chunk-VFSEVDCQ.js";
|
|
5
|
+
import "./chunk-A53VCEEA.js";
|
|
6
6
|
import "./chunk-6QENHZZP.js";
|
|
7
7
|
export {
|
|
8
8
|
generate
|
package/dist/utils/cli-logger.js
CHANGED
|
@@ -17,6 +17,7 @@ import rehypeAutolinkHeadings from 'rehype-autolink-headings';
|
|
|
17
17
|
import { eventCatalogLikeC4 } from './src/plugins/likec4';
|
|
18
18
|
import { loadAstroCompressIntegration } from './src/plugins/astro-compress';
|
|
19
19
|
import { astroTrailingSlashEndpointFix } from './src/plugins/astro-trailing-slash-endpoint-fix';
|
|
20
|
+
import { linkValidation } from './src/plugins/link-validation';
|
|
20
21
|
|
|
21
22
|
import rehypeExpressiveCode from 'rehype-expressive-code';
|
|
22
23
|
|
|
@@ -111,6 +112,7 @@ export default defineConfig({
|
|
|
111
112
|
effectiveOutput !== 'server' && compress && (await loadAstroCompressIntegration(projectDirectory)),
|
|
112
113
|
ecstudioWatcher(),
|
|
113
114
|
eventCatalogIntegration(),
|
|
115
|
+
linkValidation(config.linkValidation),
|
|
114
116
|
].filter(Boolean),
|
|
115
117
|
vite: {
|
|
116
118
|
plugins: [
|
|
@@ -16,6 +16,12 @@ import { getCollection } from 'astro:content';
|
|
|
16
16
|
import { getServiceSpecifications, getSpecUrl, getSpecLabel } from '@components/Grids/specification-utils';
|
|
17
17
|
import { getResourceReferenceStyle } from '@utils/resource-reference-colors';
|
|
18
18
|
import { isIconPath, resolveIconUrl } from '@utils/icon';
|
|
19
|
+
import {
|
|
20
|
+
getResourceReferenceUrl,
|
|
21
|
+
isVersionedReference,
|
|
22
|
+
resolveMessageReference,
|
|
23
|
+
resolveOwnerReference,
|
|
24
|
+
} from '@utils/resource-reference-links';
|
|
19
25
|
|
|
20
26
|
interface Props {
|
|
21
27
|
type:
|
|
@@ -157,18 +163,16 @@ try {
|
|
|
157
163
|
}
|
|
158
164
|
|
|
159
165
|
const resourcesCollection = (await getCollection(collection as any)) as { data: { id: string; version: string } }[];
|
|
160
|
-
const resources =
|
|
166
|
+
const resources = isVersionedReference(collection)
|
|
167
|
+
? getItemsFromCollectionByIdAndSemverOrLatest(resourcesCollection, resourceId, version)
|
|
168
|
+
: resourcesCollection.filter((item) => item.data.id === resourceId);
|
|
161
169
|
|
|
162
170
|
if (resources.length === 0) {
|
|
163
171
|
throw new Error(`Resource not found: ${resourceId}`);
|
|
164
172
|
}
|
|
165
173
|
|
|
166
174
|
resource = resources[0];
|
|
167
|
-
|
|
168
|
-
href =
|
|
169
|
-
type === 'diagram'
|
|
170
|
-
? buildUrl(`/diagrams/${resourceId}/${resource.data.version}`)
|
|
171
|
-
: buildUrl(`/docs/${collection}/${resourceId}/${resource.data.version}`);
|
|
175
|
+
href = getResourceReferenceUrl(collection, resourceId, resource.data.version);
|
|
172
176
|
}
|
|
173
177
|
} catch (error) {
|
|
174
178
|
hasError = true;
|
|
@@ -180,7 +184,7 @@ const maxSummaryLength = 120;
|
|
|
180
184
|
const summary = resource?.data?.summary || '';
|
|
181
185
|
const truncatedSummary = summary.length > maxSummaryLength ? summary.slice(0, maxSummaryLength) + '...' : summary;
|
|
182
186
|
|
|
183
|
-
const isVersionedResource = type !== 'doc';
|
|
187
|
+
const isVersionedResource = type !== 'doc' && isVersionedReference(collection);
|
|
184
188
|
|
|
185
189
|
// Only these types have visualizers
|
|
186
190
|
const hasVisualizer = ['agent', 'domain', 'service', 'event', 'query', 'command', 'container', 'system'].includes(type);
|
|
@@ -191,7 +195,7 @@ const isDeprecated = deprecation?.isMarkedAsDeprecated || false;
|
|
|
191
195
|
const resourceIconUrl = isIconPath(resource?.data?.styles?.icon) ? resolveIconUrl(resource.data.styles.icon) : null;
|
|
192
196
|
|
|
193
197
|
// Get owners (first 2)
|
|
194
|
-
const owners = resource?.data?.owners?.slice(0, 2) || [];
|
|
198
|
+
const owners = await Promise.all((resource?.data?.owners?.slice(0, 2) || []).map(resolveOwnerReference));
|
|
195
199
|
|
|
196
200
|
// Check if message type has a schema
|
|
197
201
|
const isMessageType = ['event', 'command', 'query'].includes(type);
|
|
@@ -207,39 +211,16 @@ const receives = resource?.data?.receives || [];
|
|
|
207
211
|
const isService = type === 'service' || type === 'agent';
|
|
208
212
|
const supportsSpecifications = type === 'service';
|
|
209
213
|
|
|
210
|
-
// Helper to resolve message version and collection - use specified version or fetch from collection
|
|
211
|
-
const resolveMessage = async (msg: any): Promise<{ version: string | null; collection: string | null }> => {
|
|
212
|
-
// If version is specified and not "latest", use it (assume event as default collection)
|
|
213
|
-
if (msg.version && msg.version !== 'latest') {
|
|
214
|
-
return { version: msg.version, collection: 'events' };
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// Try to find the message in events, commands, or queries collections
|
|
218
|
-
const collections = ['events', 'commands', 'queries'];
|
|
219
|
-
for (const col of collections) {
|
|
220
|
-
try {
|
|
221
|
-
const items = (await getCollection(col as any)) as { data: { id: string; version: string } }[];
|
|
222
|
-
const found = getItemsFromCollectionByIdAndSemverOrLatest(items, msg.id);
|
|
223
|
-
if (found.length > 0 && found[0].data.version && found[0].data.version !== 'latest') {
|
|
224
|
-
return { version: found[0].data.version, collection: col };
|
|
225
|
-
}
|
|
226
|
-
} catch (e) {
|
|
227
|
-
// Collection might not exist or item not found, continue
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
return { version: null, collection: null };
|
|
231
|
-
};
|
|
232
|
-
|
|
233
214
|
// Resolve versions and collections for messages to show
|
|
234
215
|
const sendsWithVersions = await Promise.all(
|
|
235
216
|
sends.slice(0, maxMessages).map(async (msg: any) => {
|
|
236
|
-
const resolved = await
|
|
217
|
+
const resolved = await resolveMessageReference(msg);
|
|
237
218
|
return { ...msg, resolvedVersion: resolved.version, resolvedCollection: resolved.collection };
|
|
238
219
|
})
|
|
239
220
|
);
|
|
240
221
|
const receivesWithVersions = await Promise.all(
|
|
241
222
|
receives.slice(0, maxMessages).map(async (msg: any) => {
|
|
242
|
-
const resolved = await
|
|
223
|
+
const resolved = await resolveMessageReference(msg);
|
|
243
224
|
return { ...msg, resolvedVersion: resolved.version, resolvedCollection: resolved.collection };
|
|
244
225
|
})
|
|
245
226
|
);
|
|
@@ -422,15 +403,16 @@ const tooltipId = `ref-tooltip-${Math.random().toString(36).slice(2, 9)}`;
|
|
|
422
403
|
<span class="text-[rgb(var(--ec-page-text-muted))]">Owner</span>
|
|
423
404
|
<span class="font-mono text-[rgb(var(--ec-page-text))]">
|
|
424
405
|
{owners.map((o: any, idx: number) => {
|
|
425
|
-
const ownerId =
|
|
406
|
+
const ownerId = o.id;
|
|
426
407
|
return (
|
|
427
408
|
<>
|
|
428
|
-
|
|
429
|
-
href={
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
409
|
+
{o.href ? (
|
|
410
|
+
<a href={o.href} class="hover:underline hover:text-[rgb(var(--ec-accent))]">
|
|
411
|
+
{ownerId}
|
|
412
|
+
</a>
|
|
413
|
+
) : (
|
|
414
|
+
<span>{ownerId}</span>
|
|
415
|
+
)}
|
|
434
416
|
{idx < owners.length - 1 && ', '}
|
|
435
417
|
</>
|
|
436
418
|
);
|
|
@@ -1107,7 +1107,7 @@ const teams = defineCollection({
|
|
|
1107
1107
|
|
|
1108
1108
|
const designs = defineCollection({
|
|
1109
1109
|
loader: async () => {
|
|
1110
|
-
const data = await globPackage('**/**/*.ecstudio', { cwd: projectDirBase, ignore: ['dist/**'] });
|
|
1110
|
+
const data = await globPackage('**/**/*.ecstudio', { cwd: projectDirBase, ignore: ['dist/**', '**/node_modules/**'] });
|
|
1111
1111
|
// File all the files in the designs folder
|
|
1112
1112
|
// Limit 3 designs community edition?
|
|
1113
1113
|
const files = data.reduce<{ id: string; name: string }[]>((acc, filePath) => {
|
|
@@ -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
|
+
};
|
|
@@ -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]);
|
|
@@ -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(
|
|
@@ -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
|
+
};
|
package/package.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
"license": "SEE LICENSE IN LICENSE",
|
|
9
9
|
"type": "module",
|
|
10
|
-
"version": "4.10.
|
|
10
|
+
"version": "4.10.12",
|
|
11
11
|
"publishConfig": {
|
|
12
12
|
"access": "public"
|
|
13
13
|
},
|
|
@@ -94,6 +94,7 @@
|
|
|
94
94
|
"nanostores": "^1.1.0",
|
|
95
95
|
"pagefind": "^1.5.2",
|
|
96
96
|
"pako": "^2.1.0",
|
|
97
|
+
"parse5": "^7.3.0",
|
|
97
98
|
"picocolors": "^1.1.1",
|
|
98
99
|
"picomatch": "^4.0.4",
|
|
99
100
|
"react": "^18.3.1",
|
|
@@ -118,9 +119,9 @@
|
|
|
118
119
|
"update-notifier": "^7.3.1",
|
|
119
120
|
"uuid": "^11.1.1",
|
|
120
121
|
"zod": "^4.3.6",
|
|
121
|
-
"@eventcatalog/linter": "1.1.
|
|
122
|
-
"@eventcatalog/
|
|
123
|
-
"@eventcatalog/
|
|
122
|
+
"@eventcatalog/linter": "1.1.19",
|
|
123
|
+
"@eventcatalog/visualiser": "^4.1.4",
|
|
124
|
+
"@eventcatalog/sdk": "2.29.0"
|
|
124
125
|
},
|
|
125
126
|
"devDependencies": {
|
|
126
127
|
"@astrojs/check": "^0.9.10",
|