@eventcatalog/core 4.9.0 → 4.10.1
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-HKVOSQTD.js → chunk-OC2RGFPU.js} +1 -1
- package/dist/{chunk-MPHX5C7M.js → chunk-PHG7KEUH.js} +1 -1
- package/dist/{chunk-LVGQ23LR.js → chunk-QIV7LWVJ.js} +1 -1
- package/dist/{chunk-XQHS36GO.js → chunk-SQNVL7PI.js} +1 -1
- package/dist/{chunk-3CGSSMTK.js → chunk-T2HLSITU.js} +1 -1
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +1 -1
- 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 +6 -0
- package/eventcatalog/src/components/Badge.astro +3 -1
- package/eventcatalog/src/components/CatalogGraph/CatalogForceGraph.tsx +260 -175
- package/eventcatalog/src/components/FieldsExplorer/FieldNodeGraph.tsx +0 -5
- package/eventcatalog/src/components/MDX/ArchitectureGraph/ArchitectureGraph.astro +62 -0
- package/eventcatalog/src/components/MDX/ArchitectureGraph/ArchitectureGraphPortal.tsx +22 -0
- package/eventcatalog/src/components/MDX/ArchitectureGraph/AstroArchitectureGraph.tsx +42 -0
- package/eventcatalog/src/components/MDX/Design/Design.astro +2 -0
- package/eventcatalog/src/components/MDX/EntityMap/EntityMap.astro +2 -0
- package/eventcatalog/src/components/MDX/Flow/Flow.astro +2 -0
- package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +5 -7
- package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +5 -0
- package/eventcatalog/src/components/MDX/components.tsx +13 -0
- package/eventcatalog/src/components/SideNav/NestedSideBar/index.tsx +121 -88
- package/eventcatalog/src/components/SideNav/NestedSideBar/storage.ts +55 -7
- package/eventcatalog/src/components/SideNav/NestedSideBar/utils.spec.ts +59 -15
- package/eventcatalog/src/components/SideNav/NestedSideBar/utils.ts +53 -7
- package/eventcatalog/src/content.config.ts +70 -0
- package/eventcatalog/src/enterprise/collections/resource-docs-utils.ts +19 -0
- package/eventcatalog/src/enterprise/custom-documentation/pages/docs/custom/index.astro +46 -1
- package/eventcatalog/src/enterprise/fields/pages/fields.astro +2 -0
- package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/index.astro +1 -1
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/[docType]/[docId]/[docVersion]/index.astro +16 -15
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/[docType]/[docId]/index.astro +21 -17
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/graphql/[filename].astro +1 -1
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/index.astro +45 -4
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/index.astro +16 -8
- package/eventcatalog/src/pages/docs/[type]/[id]/language/[dictionaryId]/index.astro +48 -65
- package/eventcatalog/src/pages/docs/[type]/[id]/language/index.astro +45 -18
- package/eventcatalog/src/pages/docs/teams/[id]/index.astro +1 -1
- package/eventcatalog/src/pages/docs/users/[id]/index.astro +1 -1
- package/eventcatalog/src/pages/index.astro +34 -2
- package/eventcatalog/src/pages/schemas/[type]/[id]/[version]/index.astro +5 -1
- package/eventcatalog/src/pages/triggers/[type]/[id]/[version]/index.astro +2 -0
- package/eventcatalog/src/pages/visualiser/designs/[id]/index.astro +2 -0
- package/eventcatalog/src/pages/visualiser/graph/index.astro +2 -23
- package/eventcatalog/src/stores/sidebar-store/builders/adr.ts +96 -34
- package/eventcatalog/src/stores/sidebar-store/builders/agent.ts +167 -75
- package/eventcatalog/src/stores/sidebar-store/builders/container.ts +135 -59
- package/eventcatalog/src/stores/sidebar-store/builders/data-product.ts +144 -58
- package/eventcatalog/src/stores/sidebar-store/builders/domain.ts +306 -188
- package/eventcatalog/src/stores/sidebar-store/builders/entity.ts +108 -39
- package/eventcatalog/src/stores/sidebar-store/builders/flow.ts +147 -63
- package/eventcatalog/src/stores/sidebar-store/builders/message.ts +212 -121
- package/eventcatalog/src/stores/sidebar-store/builders/service.ts +223 -127
- package/eventcatalog/src/stores/sidebar-store/builders/shared.ts +3 -23
- package/eventcatalog/src/stores/sidebar-store/builders/system.ts +187 -97
- package/eventcatalog/src/stores/sidebar-store/custom-sidebar.ts +678 -0
- package/eventcatalog/src/stores/sidebar-store/state.ts +66 -36
- package/eventcatalog/src/styles/tailwind.css +82 -0
- package/eventcatalog/src/utils/collections/resource-docs.ts +1 -0
- package/eventcatalog/src/utils/node-graphs/catalog-force-graph.ts +41 -0
- package/package.json +1 -1
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import type { CollectionEntry } from 'astro:content';
|
|
3
|
+
import { buildUrl } from '@utils/url-builder';
|
|
4
|
+
import { processSpecifications } from '@utils/collections/util';
|
|
5
|
+
import type { ResourceCollection, ResourceDocEntry } from '@utils/collections/resource-docs';
|
|
6
|
+
import type { NavNode, ChildRef } from './builders/shared';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Custom sidebars: an optional `sidebar.json` next to any resource's `index.mdx` — domains,
|
|
10
|
+
* systems, services, agents, events, commands, queries, flows, containers, entities,
|
|
11
|
+
* data products and ADRs. Each builder exposes its own predefined `$tokens` (see the
|
|
12
|
+
* `<Type>SectionKey` union and `DEFAULT_<TYPE>_SECTION_ORDER` in `builders/<type>.ts`).
|
|
13
|
+
*
|
|
14
|
+
* When the file exists it *is* the sidebar — nothing is merged in, nothing is hidden,
|
|
15
|
+
* what's listed is what renders, in that order. The grammar is intentionally tiny:
|
|
16
|
+
*
|
|
17
|
+
* A section is one of
|
|
18
|
+
* "$quick-reference" predefined section (kept live)
|
|
19
|
+
* { "section": "$owners", "title": "Team" } predefined section, relabelled
|
|
20
|
+
* { "title": "Runbooks", "icon": "Siren", "pages": [] } a custom group
|
|
21
|
+
*
|
|
22
|
+
* A page (inside a custom group) is one of
|
|
23
|
+
* "$quick-reference" the *items* of a predefined section, spliced in
|
|
24
|
+
* "[[service|OrderService@1.0.0]]" a resource (nested sidebar)
|
|
25
|
+
* "[[doc|guides/sla]]" one of this resource's docs
|
|
26
|
+
* "[[spec|openapi.yml]]" one of this resource's specifications
|
|
27
|
+
* "[[spec|service/product-api@1.0.0/openapi.yml]]" another resource's specification
|
|
28
|
+
* "[[schema|event/product-created@1.0.0]]" a message's schema page
|
|
29
|
+
* { "title": "Runbook", "href": "https://..." } a plain link (external inferred)
|
|
30
|
+
* { "title": "Runbooks", "pages": [] } a nested group (rendered as a subsection)
|
|
31
|
+
*
|
|
32
|
+
* Any object-form section or group accepts `"collapsed": true | false` — an explicit initial
|
|
33
|
+
* state that overrides the renderer's size-based heuristic. Users can still toggle it.
|
|
34
|
+
*
|
|
35
|
+
* Splicing is how you extend a predefined section: wrap it in your own group and put
|
|
36
|
+
* your pages before or after the token — placement is just list order.
|
|
37
|
+
*
|
|
38
|
+
* Link titles and hrefs may use `{id}`, `{version}` and `{collection}` placeholders for the
|
|
39
|
+
* resource the sidebar belongs to, e.g. "/visualiser/{collection}/{id}/{version}". Internal
|
|
40
|
+
* hrefs (no protocol) are passed through `buildUrl` so they respect the configured base path.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
export type SidebarLink = { title: string; href: string; icon?: string };
|
|
44
|
+
export type SidebarCustomGroup = { title: string; icon?: string; collapsed?: boolean; pages: SidebarPageEntry[] };
|
|
45
|
+
export type SidebarPageEntry = string | SidebarLink | SidebarCustomGroup;
|
|
46
|
+
export type SidebarSectionOverride = { section: string; title?: string; icon?: string; collapsed?: boolean };
|
|
47
|
+
export type SidebarSectionEntry = string | SidebarSectionOverride | SidebarCustomGroup;
|
|
48
|
+
|
|
49
|
+
export type SidebarSpec = {
|
|
50
|
+
$schema?: string;
|
|
51
|
+
sections: SidebarSectionEntry[];
|
|
52
|
+
/** Where the spec came from — used in error messages only. */
|
|
53
|
+
sourcePath?: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The predefined sections a builder exposes for a resource, keyed by token (without the `$`).
|
|
58
|
+
* Values are what the builder would normally render: null/false/undefined when the section
|
|
59
|
+
* has nothing to show, or an array for tokens that expand to several groups (e.g. resource groups).
|
|
60
|
+
*/
|
|
61
|
+
export type SidebarSections = Record<string, NavNode | NavNode[] | null | undefined | false>;
|
|
62
|
+
|
|
63
|
+
export type CustomSidebarResource = {
|
|
64
|
+
/** The Astro collection name, e.g. `domains`, `services`, `events`, `adrs`. */
|
|
65
|
+
collection: ResourceCollection | string;
|
|
66
|
+
id: string;
|
|
67
|
+
version: string;
|
|
68
|
+
/** The resource's own collection entry — needed to resolve its own `[[spec|…]]` refs. */
|
|
69
|
+
entry?: { data: Record<string, any> };
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
type EntryLike = { collection: string; data: Record<string, any> };
|
|
73
|
+
|
|
74
|
+
export type CustomSidebarContext = {
|
|
75
|
+
resourceDocs?: ResourceDocEntry[];
|
|
76
|
+
/**
|
|
77
|
+
* Whether the resource-docs feature is available on this plan. When explicitly false
|
|
78
|
+
* (community mode) `[[doc|…]]` refs render nothing — the pages they'd link to don't
|
|
79
|
+
* exist — instead of failing the build. Missing docs still fail when the feature is on.
|
|
80
|
+
*/
|
|
81
|
+
resourceDocsEnabled?: boolean;
|
|
82
|
+
domains?: EntryLike[];
|
|
83
|
+
services?: EntryLike[];
|
|
84
|
+
systems?: EntryLike[];
|
|
85
|
+
agents?: EntryLike[];
|
|
86
|
+
flows?: EntryLike[];
|
|
87
|
+
containers?: EntryLike[];
|
|
88
|
+
entities?: EntryLike[];
|
|
89
|
+
dataProducts?: EntryLike[];
|
|
90
|
+
adrs?: EntryLike[];
|
|
91
|
+
channels?: EntryLike[];
|
|
92
|
+
diagrams?: EntryLike[];
|
|
93
|
+
events?: EntryLike[];
|
|
94
|
+
commands?: EntryLike[];
|
|
95
|
+
queries?: EntryLike[];
|
|
96
|
+
schemas?: Array<{ data: { message: { collectionName: string; id: string; version: string } } }>;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** `[type/]id[@version]` — the resource half of a spec or schema ref. */
|
|
100
|
+
const parseResourceLocator = (value: string): { type?: string; id: string; version?: string } => {
|
|
101
|
+
let rest = value.trim();
|
|
102
|
+
let type: string | undefined;
|
|
103
|
+
const slash = rest.indexOf('/');
|
|
104
|
+
if (slash !== -1) {
|
|
105
|
+
type = rest.slice(0, slash).toLowerCase();
|
|
106
|
+
rest = rest.slice(slash + 1);
|
|
107
|
+
}
|
|
108
|
+
const at = rest.lastIndexOf('@');
|
|
109
|
+
if (at > 0 && /^[\d.]+$/.test(rest.slice(at + 1))) {
|
|
110
|
+
return { type, id: rest.slice(0, at), version: rest.slice(at + 1) };
|
|
111
|
+
}
|
|
112
|
+
return { type, id: rest };
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const SPEC_OWNER_COLLECTIONS: Record<string, keyof CustomSidebarContext> = {
|
|
116
|
+
domain: 'domains',
|
|
117
|
+
service: 'services',
|
|
118
|
+
// Messages extend the same base schema and get specification pages too.
|
|
119
|
+
event: 'events',
|
|
120
|
+
command: 'commands',
|
|
121
|
+
query: 'queries',
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const SPEC_ROUTES: Record<string, { segment: string; icon: string }> = {
|
|
125
|
+
openapi: { segment: 'spec', icon: '/icons/openapi-black.svg' },
|
|
126
|
+
asyncapi: { segment: 'asyncapi', icon: '/icons/asyncapi-black.svg' },
|
|
127
|
+
graphql: { segment: 'graphql', icon: '/icons/graphql-black.svg' },
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const MESSAGE_COLLECTIONS: Record<string, keyof CustomSidebarContext> = {
|
|
131
|
+
event: 'events',
|
|
132
|
+
command: 'commands',
|
|
133
|
+
query: 'queries',
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Sidebar nodes exist for every version of a message, but only the latest version of the
|
|
138
|
+
* other collections (state.ts loads them with `getAllVersions: false`). A ref pinned to a
|
|
139
|
+
* historical version of those would silently render nothing, so fail the build instead.
|
|
140
|
+
* Maps ref types to the context collection we can validate against.
|
|
141
|
+
*/
|
|
142
|
+
const LATEST_ONLY_COLLECTIONS: Record<string, keyof CustomSidebarContext> = {
|
|
143
|
+
domain: 'domains',
|
|
144
|
+
service: 'services',
|
|
145
|
+
system: 'systems',
|
|
146
|
+
agent: 'agents',
|
|
147
|
+
flow: 'flows',
|
|
148
|
+
container: 'containers',
|
|
149
|
+
entity: 'entities',
|
|
150
|
+
'data-product': 'dataProducts',
|
|
151
|
+
adr: 'adrs',
|
|
152
|
+
channel: 'channels',
|
|
153
|
+
diagram: 'diagrams',
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const RESOURCE_REF_PATTERN = /^\[\[([a-z-]+)\|([^[\]]+?)\]\]$/;
|
|
157
|
+
const EXTERNAL_HREF_PATTERN = /^[a-z][a-z0-9+.-]*:/i; // any protocol: https:, mailto:, slack:, ...
|
|
158
|
+
const PLACEHOLDER_PATTERN = /\{(id|version|collection)\}/g;
|
|
159
|
+
|
|
160
|
+
/** Replace `{id}`, `{version}` and `{collection}` with the owning resource's values. */
|
|
161
|
+
export const interpolateResourcePlaceholders = (value: string, resource: CustomSidebarResource): string =>
|
|
162
|
+
value.replace(PLACEHOLDER_PATTERN, (_match, key: 'id' | 'version' | 'collection') => resource[key]);
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Maps `[[type|...]]` ref types to the node-key prefix used in the sidebar node map
|
|
166
|
+
* (see state.ts). Messages are keyed by their singular type (`event:`, `command:`, `query:`).
|
|
167
|
+
*/
|
|
168
|
+
const REF_TYPE_TO_NODE_PREFIX: Record<string, string> = {
|
|
169
|
+
domain: 'domain',
|
|
170
|
+
system: 'system',
|
|
171
|
+
service: 'service',
|
|
172
|
+
agent: 'agent',
|
|
173
|
+
flow: 'flow',
|
|
174
|
+
container: 'container',
|
|
175
|
+
entity: 'entity',
|
|
176
|
+
channel: 'channel',
|
|
177
|
+
diagram: 'diagram',
|
|
178
|
+
team: 'team',
|
|
179
|
+
user: 'user',
|
|
180
|
+
'data-product': 'data-product',
|
|
181
|
+
adr: 'adr',
|
|
182
|
+
event: 'event',
|
|
183
|
+
command: 'command',
|
|
184
|
+
query: 'query',
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const slugify = (value: string) =>
|
|
188
|
+
value
|
|
189
|
+
.toLowerCase()
|
|
190
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
191
|
+
.replace(/^-|-$/g, '');
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Collapse state is persisted per collapseKey, so sibling groups must never share one —
|
|
195
|
+
* duplicate titles (or titles that slugify to nothing, e.g. non-Latin) get a numeric
|
|
196
|
+
* suffix. Keys stay stable for the common unique-title case.
|
|
197
|
+
*/
|
|
198
|
+
const uniqueCollapseKey = (candidate: string, usedKeys: Set<string>): string => {
|
|
199
|
+
let key = candidate;
|
|
200
|
+
for (let n = 2; usedKeys.has(key); n++) {
|
|
201
|
+
key = `${candidate}-${n}`;
|
|
202
|
+
}
|
|
203
|
+
usedKeys.add(key);
|
|
204
|
+
return key;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const describeSource = (spec: SidebarSpec) => (spec.sourcePath ? ` (${spec.sourcePath})` : '');
|
|
208
|
+
|
|
209
|
+
const formatTokenList = (sections: SidebarSections) =>
|
|
210
|
+
Object.keys(sections)
|
|
211
|
+
.map((key) => `$${key}`)
|
|
212
|
+
.join(', ');
|
|
213
|
+
|
|
214
|
+
export const parseResourceRef = (value: string): { type: string; id: string; version?: string } | null => {
|
|
215
|
+
const match = value.trim().match(RESOURCE_REF_PATTERN);
|
|
216
|
+
if (!match) return null;
|
|
217
|
+
|
|
218
|
+
const type = match[1].trim().toLowerCase();
|
|
219
|
+
let id = match[2].trim();
|
|
220
|
+
let version: string | undefined;
|
|
221
|
+
|
|
222
|
+
// Docs and specs are addressed by path and carry any version mid-string, not at the end.
|
|
223
|
+
if (type !== 'doc' && type !== 'spec') {
|
|
224
|
+
const versionMatch = id.match(/^(.*)@([\d.]+)$/);
|
|
225
|
+
if (versionMatch) {
|
|
226
|
+
id = versionMatch[1].trim();
|
|
227
|
+
version = versionMatch[2];
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { type, id, version };
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const resolveDocPage = (
|
|
235
|
+
ref: { id: string },
|
|
236
|
+
resource: CustomSidebarResource,
|
|
237
|
+
context: CustomSidebarContext,
|
|
238
|
+
spec: SidebarSpec
|
|
239
|
+
): NavNode | null => {
|
|
240
|
+
if (context.resourceDocsEnabled === false) return null;
|
|
241
|
+
|
|
242
|
+
const [docType, ...rest] = ref.id.split('/');
|
|
243
|
+
const docId = rest.join('/');
|
|
244
|
+
|
|
245
|
+
if (!docType || !docId) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
`Invalid doc reference "[[doc|${ref.id}]]" in sidebar${describeSource(spec)}. Expected "[[doc|<type>/<id>]]", e.g. "[[doc|guides/onboarding]]".`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const docsForResource = (context.resourceDocs || []).filter(
|
|
252
|
+
(entry) =>
|
|
253
|
+
entry.data.resourceCollection === resource.collection &&
|
|
254
|
+
entry.data.resourceId === resource.id &&
|
|
255
|
+
entry.data.resourceVersion === resource.version
|
|
256
|
+
);
|
|
257
|
+
const doc = docsForResource.find((entry) => entry.data.type === docType && entry.data.id === docId);
|
|
258
|
+
|
|
259
|
+
if (!doc) {
|
|
260
|
+
const available = docsForResource.map((entry) => `${entry.data.type}/${entry.data.id}`).join(', ');
|
|
261
|
+
throw new Error(
|
|
262
|
+
`Cannot resolve "[[doc|${ref.id}]]" in sidebar${describeSource(spec)}: ${resource.id} v${resource.version} has no documentation page "${ref.id}".${available ? ` Available: ${available}.` : ' This resource has no documentation pages.'}`
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
type: 'item',
|
|
268
|
+
title: doc.data.title || docId,
|
|
269
|
+
href: buildUrl(
|
|
270
|
+
`/docs/${resource.collection}/${resource.id}/${resource.version}/${encodeURIComponent(docType)}/${encodeURIComponent(docId)}`
|
|
271
|
+
),
|
|
272
|
+
};
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const findEntry = (collections: Array<EntryLike[] | undefined>, id: string, version?: string): EntryLike | undefined => {
|
|
276
|
+
for (const collection of collections) {
|
|
277
|
+
const match = (collection || []).find(
|
|
278
|
+
(entry) => entry.data.id === id && (version === undefined || entry.data.version === version)
|
|
279
|
+
);
|
|
280
|
+
if (match) return match;
|
|
281
|
+
}
|
|
282
|
+
return undefined;
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* `[[spec|<filename>]]` (this resource) or `[[spec|[type/]<id>[@version]/<filename>]]` (any resource).
|
|
287
|
+
*/
|
|
288
|
+
const resolveSpecPage = (
|
|
289
|
+
target: string,
|
|
290
|
+
resource: CustomSidebarResource,
|
|
291
|
+
context: CustomSidebarContext,
|
|
292
|
+
spec: SidebarSpec
|
|
293
|
+
): NavNode => {
|
|
294
|
+
const lastSlash = target.lastIndexOf('/');
|
|
295
|
+
const filename = lastSlash === -1 ? target : target.slice(lastSlash + 1);
|
|
296
|
+
const locator = lastSlash === -1 ? null : parseResourceLocator(target.slice(0, lastSlash));
|
|
297
|
+
|
|
298
|
+
let owner: { collection: string; id: string; version: string; data: Record<string, any> } | undefined;
|
|
299
|
+
if (!locator) {
|
|
300
|
+
if (resource.entry)
|
|
301
|
+
owner = { collection: resource.collection, id: resource.id, version: resource.version, data: resource.entry.data };
|
|
302
|
+
} else {
|
|
303
|
+
if (locator.type && !SPEC_OWNER_COLLECTIONS[locator.type]) {
|
|
304
|
+
throw new Error(
|
|
305
|
+
`Unknown resource type "${locator.type}" in "[[spec|${target}]]" in sidebar${describeSource(spec)}. Specifications belong to: ${Object.keys(SPEC_OWNER_COLLECTIONS).join(', ')}.`
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
const pools = locator.type
|
|
309
|
+
? [context[SPEC_OWNER_COLLECTIONS[locator.type]] as EntryLike[] | undefined]
|
|
310
|
+
: [context.services, context.domains];
|
|
311
|
+
const entry = findEntry(pools, locator.id, locator.version);
|
|
312
|
+
if (entry) {
|
|
313
|
+
owner = { collection: entry.collection, id: entry.data.id, version: entry.data.version, data: entry.data };
|
|
314
|
+
} else if (locator.version) {
|
|
315
|
+
// The id may exist at a different version: only the latest version's specifications
|
|
316
|
+
// can be referenced (state.ts loads these collections latest-only).
|
|
317
|
+
const latest = findEntry(pools, locator.id);
|
|
318
|
+
if (latest) {
|
|
319
|
+
throw new Error(
|
|
320
|
+
`Cannot resolve "[[spec|${target}]]" in sidebar${describeSource(spec)}: only the latest version (${latest.data.version}) of "${locator.id}" can be referenced. Drop the "@${locator.version}" to link its latest specifications.`
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (!owner) {
|
|
327
|
+
throw new Error(
|
|
328
|
+
`Cannot resolve "[[spec|${target}]]" in sidebar${describeSource(spec)}: resource${locator ? ` "${locator.id}"` : ''} not found.`
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const wanted = filename.replace(/\.[^.]+$/, '');
|
|
333
|
+
const specification = processSpecifications(owner.data.specifications).find(
|
|
334
|
+
(candidate) => candidate.filename === filename || candidate.filenameWithoutExtension === wanted
|
|
335
|
+
);
|
|
336
|
+
const route = specification && SPEC_ROUTES[specification.type];
|
|
337
|
+
if (!specification || !route) {
|
|
338
|
+
throw new Error(
|
|
339
|
+
`Cannot resolve "[[spec|${target}]]" in sidebar${describeSource(spec)}: "${owner.id}" has no specification file "${filename}".`
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
type: 'item',
|
|
345
|
+
title: specification.name,
|
|
346
|
+
leftIcon: route.icon,
|
|
347
|
+
href: buildUrl(
|
|
348
|
+
`/docs/${owner.collection}/${owner.id}/${owner.version}/${route.segment}/${specification.filenameWithoutExtension}`
|
|
349
|
+
),
|
|
350
|
+
};
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* `[[schema|[type/]<message id>[@version]]]` — a message's schema page.
|
|
355
|
+
*/
|
|
356
|
+
const resolveSchemaPage = (target: string, context: CustomSidebarContext, spec: SidebarSpec): NavNode => {
|
|
357
|
+
const locator = parseResourceLocator(target);
|
|
358
|
+
if (locator.type && !MESSAGE_COLLECTIONS[locator.type]) {
|
|
359
|
+
throw new Error(
|
|
360
|
+
`Unknown message type "${locator.type}" in "[[schema|${target}]]" in sidebar${describeSource(spec)}. Schemas belong to: ${Object.keys(MESSAGE_COLLECTIONS).join(', ')}.`
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const pools = locator.type
|
|
365
|
+
? [context[MESSAGE_COLLECTIONS[locator.type]] as EntryLike[] | undefined]
|
|
366
|
+
: [context.events, context.commands, context.queries];
|
|
367
|
+
const message = findEntry(pools, locator.id);
|
|
368
|
+
if (!message) {
|
|
369
|
+
throw new Error(
|
|
370
|
+
`Cannot resolve "[[schema|${target}]]" in sidebar${describeSource(spec)}: message "${locator.id}" not found.`
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const version = locator.version || message.data.version;
|
|
375
|
+
if (context.schemas) {
|
|
376
|
+
const hasSchema = context.schemas.some(
|
|
377
|
+
(schema) =>
|
|
378
|
+
schema.data.message.collectionName === message.collection &&
|
|
379
|
+
schema.data.message.id === message.data.id &&
|
|
380
|
+
schema.data.message.version === version
|
|
381
|
+
);
|
|
382
|
+
if (!hasSchema) {
|
|
383
|
+
throw new Error(
|
|
384
|
+
`Cannot resolve "[[schema|${target}]]" in sidebar${describeSource(spec)}: "${message.data.id}" v${version} has no schema.`
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
type: 'item',
|
|
391
|
+
title: `${message.data.name || message.data.id} schema`,
|
|
392
|
+
href: buildUrl(`/schemas/${message.collection}/${message.data.id}/${version}`),
|
|
393
|
+
};
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* `$token` inside a group's pages splices in the predefined section's items
|
|
398
|
+
* (not the section itself), so users can extend a section with their own pages.
|
|
399
|
+
*/
|
|
400
|
+
const splicePredefinedSectionPages = (token: string, sections: SidebarSections, spec: SidebarSpec): ChildRef[] => {
|
|
401
|
+
const nodes = resolvePredefinedSection(token, {}, sections, spec);
|
|
402
|
+
if (nodes.length > 1) {
|
|
403
|
+
throw new Error(
|
|
404
|
+
`Cannot splice "${token}" into a group in sidebar${describeSource(spec)} because it expands to several groups. Use it as a top-level section instead.`
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
return nodes.flatMap((node) => node.pages || []);
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const isNestedGroup = (page: SidebarPageEntry): page is SidebarCustomGroup => typeof page !== 'string' && 'pages' in page;
|
|
411
|
+
|
|
412
|
+
const resolvePage = (
|
|
413
|
+
page: SidebarPageEntry,
|
|
414
|
+
sections: SidebarSections,
|
|
415
|
+
resource: CustomSidebarResource,
|
|
416
|
+
context: CustomSidebarContext,
|
|
417
|
+
spec: SidebarSpec,
|
|
418
|
+
usedCollapseKeys: Set<string>,
|
|
419
|
+
parentKey: string
|
|
420
|
+
): ChildRef[] => {
|
|
421
|
+
if (isNestedGroup(page)) {
|
|
422
|
+
return [resolveCustomGroup(page, sections, resource, context, spec, usedCollapseKeys, parentKey)];
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (typeof page !== 'string') {
|
|
426
|
+
const href = interpolateResourcePlaceholders(page.href, resource);
|
|
427
|
+
// Protocol-relative URLs (//host/path) are external too — buildUrl would mangle them.
|
|
428
|
+
const isExternal = EXTERNAL_HREF_PATTERN.test(href) || href.startsWith('//');
|
|
429
|
+
return [
|
|
430
|
+
{
|
|
431
|
+
type: 'item',
|
|
432
|
+
title: interpolateResourcePlaceholders(page.title, resource),
|
|
433
|
+
href: isExternal ? href : buildUrl(href),
|
|
434
|
+
...(page.icon ? { icon: page.icon } : {}),
|
|
435
|
+
...(isExternal ? { external: true } : {}),
|
|
436
|
+
},
|
|
437
|
+
];
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (page.startsWith('$')) {
|
|
441
|
+
return splicePredefinedSectionPages(page, sections, spec);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const ref = parseResourceRef(page);
|
|
445
|
+
if (!ref) {
|
|
446
|
+
throw new Error(
|
|
447
|
+
`Invalid page "${page}" in sidebar${describeSource(spec)}. Pages must be a predefined section like "$quick-reference", a resource reference like "[[service|OrderService]]", a doc reference like "[[doc|guides/onboarding]]", or a link { "title", "href" }.`
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (ref.type === 'doc') {
|
|
452
|
+
const docPage = resolveDocPage(ref, resource, context, spec);
|
|
453
|
+
return docPage ? [docPage] : [];
|
|
454
|
+
}
|
|
455
|
+
if (ref.type === 'spec') {
|
|
456
|
+
return [resolveSpecPage(ref.id, resource, context, spec)];
|
|
457
|
+
}
|
|
458
|
+
if (ref.type === 'schema') {
|
|
459
|
+
return [resolveSchemaPage(ref.version ? `${ref.id}@${ref.version}` : ref.id, context, spec)];
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const prefix = REF_TYPE_TO_NODE_PREFIX[ref.type];
|
|
463
|
+
if (!prefix) {
|
|
464
|
+
throw new Error(
|
|
465
|
+
`Unknown resource type "${ref.type}" in "${page}" in sidebar${describeSource(spec)}. Supported types: ${Object.keys(REF_TYPE_TO_NODE_PREFIX).join(', ')}, doc, spec, schema.`
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Only messages have sidebar nodes for every version; pinning a historical version of a
|
|
470
|
+
// latest-only collection would silently render nothing, so fail loudly when we can tell.
|
|
471
|
+
if (ref.version && LATEST_ONLY_COLLECTIONS[ref.type]) {
|
|
472
|
+
const pool = context[LATEST_ONLY_COLLECTIONS[ref.type]] as EntryLike[] | undefined;
|
|
473
|
+
const latest = pool?.find((entry) => entry.data.id === ref.id);
|
|
474
|
+
if (latest && latest.data.version !== ref.version) {
|
|
475
|
+
throw new Error(
|
|
476
|
+
`Cannot resolve "${page}" in sidebar${describeSource(spec)}: only the latest version (${latest.data.version}) of a ${ref.type} can be referenced in a sidebar. Drop the "@${ref.version}" to link the latest version.`
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Unversioned keys are aliases to the latest version in the node map.
|
|
482
|
+
return [ref.version ? `${prefix}:${ref.id}:${ref.version}` : `${prefix}:${ref.id}`];
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
const resolvePredefinedSection = (
|
|
486
|
+
token: string,
|
|
487
|
+
overrides: { title?: string; icon?: string; collapsed?: boolean },
|
|
488
|
+
sections: SidebarSections,
|
|
489
|
+
spec: SidebarSpec
|
|
490
|
+
): NavNode[] => {
|
|
491
|
+
if (!token.startsWith('$')) {
|
|
492
|
+
throw new Error(
|
|
493
|
+
`Unknown section "${token}" in sidebar${describeSource(spec)}. Predefined sections start with "$" — did you mean "$${token}"?`
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const key = token.slice(1);
|
|
498
|
+
if (!(key in sections)) {
|
|
499
|
+
throw new Error(
|
|
500
|
+
`Unknown section "${token}" in sidebar${describeSource(spec)}. Available sections for this resource: ${formatTokenList(sections)}.`
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const value = sections[key];
|
|
505
|
+
if (!value) return [];
|
|
506
|
+
|
|
507
|
+
const nodes = Array.isArray(value) ? value : [value];
|
|
508
|
+
const hasOverrides = overrides.title !== undefined || overrides.icon !== undefined || overrides.collapsed !== undefined;
|
|
509
|
+
if (!hasOverrides) return nodes;
|
|
510
|
+
|
|
511
|
+
return nodes.map((node) => ({
|
|
512
|
+
...node,
|
|
513
|
+
...(overrides.title !== undefined ? { title: overrides.title } : {}),
|
|
514
|
+
...(overrides.icon !== undefined ? { icon: overrides.icon } : {}),
|
|
515
|
+
...(overrides.collapsed !== undefined ? { collapsed: overrides.collapsed } : {}),
|
|
516
|
+
}));
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* `parentKey` is the collapseKey of the enclosing group ('' at the top level); nested groups
|
|
521
|
+
* extend it so collapse state is persisted per path. Nested groups render as subtle
|
|
522
|
+
* subsections, matching the built-in Resources > Services/Entities look.
|
|
523
|
+
*/
|
|
524
|
+
const resolveCustomGroup = (
|
|
525
|
+
group: SidebarCustomGroup,
|
|
526
|
+
sections: SidebarSections,
|
|
527
|
+
resource: CustomSidebarResource,
|
|
528
|
+
context: CustomSidebarContext,
|
|
529
|
+
spec: SidebarSpec,
|
|
530
|
+
usedCollapseKeys: Set<string>,
|
|
531
|
+
parentKey = ''
|
|
532
|
+
): NavNode => {
|
|
533
|
+
const slug = slugify(group.title) || 'group';
|
|
534
|
+
const collapseKey = uniqueCollapseKey(
|
|
535
|
+
parentKey ? `${parentKey}:${slug}` : `custom:${resource.collection}:${resource.id}:${resource.version}:${slug}`,
|
|
536
|
+
usedCollapseKeys
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
return {
|
|
540
|
+
type: 'group',
|
|
541
|
+
title: group.title,
|
|
542
|
+
...(group.icon ? { icon: group.icon } : {}),
|
|
543
|
+
...(parentKey ? { subtle: true } : {}),
|
|
544
|
+
...(group.collapsed !== undefined ? { collapsed: group.collapsed } : {}),
|
|
545
|
+
collapseKey,
|
|
546
|
+
pages: group.pages.flatMap((page) => resolvePage(page, sections, resource, context, spec, usedCollapseKeys, collapseKey)),
|
|
547
|
+
};
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Turn a sidebar spec into the `pages` of a resource node. Throws on anything it can't
|
|
552
|
+
* resolve so a typo fails the build with a pointer at the offending file.
|
|
553
|
+
*/
|
|
554
|
+
export const applyCustomSidebar = (
|
|
555
|
+
spec: SidebarSpec,
|
|
556
|
+
sections: SidebarSections,
|
|
557
|
+
resource: CustomSidebarResource,
|
|
558
|
+
context: CustomSidebarContext = {}
|
|
559
|
+
): ChildRef[] => {
|
|
560
|
+
const usedCollapseKeys = new Set<string>();
|
|
561
|
+
return spec.sections.flatMap((entry): ChildRef[] => {
|
|
562
|
+
if (typeof entry === 'string') {
|
|
563
|
+
return resolvePredefinedSection(entry, {}, sections, spec);
|
|
564
|
+
}
|
|
565
|
+
if ('section' in entry) {
|
|
566
|
+
return resolvePredefinedSection(
|
|
567
|
+
entry.section,
|
|
568
|
+
{ title: entry.title, icon: entry.icon, collapsed: entry.collapsed },
|
|
569
|
+
sections,
|
|
570
|
+
spec
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
return [resolveCustomGroup(entry, sections, resource, context, spec, usedCollapseKeys)];
|
|
574
|
+
});
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Everything a builder needs to turn its named sections into a node's `pages`.
|
|
579
|
+
* With a `sidebar` spec the spec decides; without one the builder's default order does.
|
|
580
|
+
*/
|
|
581
|
+
export type ResolveSidebarPagesOptions = {
|
|
582
|
+
sidebar?: SidebarSpec;
|
|
583
|
+
resource: CustomSidebarResource;
|
|
584
|
+
context: CustomSidebarContext;
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Builders produce a map of named sections plus a default order. This turns that into the
|
|
589
|
+
* node's `pages`, either by applying a custom `sidebar.json` or by walking the default order.
|
|
590
|
+
* Sections that are null/false/empty render nothing in both modes.
|
|
591
|
+
*/
|
|
592
|
+
export const resolveSidebarPages = <K extends string>(
|
|
593
|
+
sections: Record<K, NavNode | NavNode[] | null | undefined | false>,
|
|
594
|
+
defaultOrder: K[],
|
|
595
|
+
{ sidebar, resource, context }: ResolveSidebarPagesOptions
|
|
596
|
+
): ChildRef[] => {
|
|
597
|
+
if (sidebar) return applyCustomSidebar(sidebar, sections as SidebarSections, resource, context);
|
|
598
|
+
return defaultOrder.flatMap((key) => {
|
|
599
|
+
const value = sections[key];
|
|
600
|
+
if (!value) return [];
|
|
601
|
+
return Array.isArray(value) ? value : [value];
|
|
602
|
+
});
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Pick the collections a custom sidebar can reference out of the builders' shared context.
|
|
607
|
+
* Builders pass their `ResourceGroupContext` here rather than spreading it by hand.
|
|
608
|
+
*/
|
|
609
|
+
export const toCustomSidebarContext = (context: {
|
|
610
|
+
resourceDocs?: ResourceDocEntry[];
|
|
611
|
+
resourceDocsEnabled?: boolean;
|
|
612
|
+
domains?: unknown[];
|
|
613
|
+
services?: unknown[];
|
|
614
|
+
systems?: unknown[];
|
|
615
|
+
agents?: unknown[];
|
|
616
|
+
flows?: unknown[];
|
|
617
|
+
containers?: unknown[];
|
|
618
|
+
entities?: unknown[];
|
|
619
|
+
dataProducts?: unknown[];
|
|
620
|
+
adrs?: unknown[];
|
|
621
|
+
channels?: unknown[];
|
|
622
|
+
diagrams?: unknown[];
|
|
623
|
+
events?: unknown[];
|
|
624
|
+
commands?: unknown[];
|
|
625
|
+
queries?: unknown[];
|
|
626
|
+
schemas?: unknown[];
|
|
627
|
+
}): CustomSidebarContext => ({
|
|
628
|
+
resourceDocs: context.resourceDocs,
|
|
629
|
+
resourceDocsEnabled: context.resourceDocsEnabled,
|
|
630
|
+
domains: context.domains as EntryLike[] | undefined,
|
|
631
|
+
services: context.services as EntryLike[] | undefined,
|
|
632
|
+
systems: context.systems as EntryLike[] | undefined,
|
|
633
|
+
agents: context.agents as EntryLike[] | undefined,
|
|
634
|
+
flows: context.flows as EntryLike[] | undefined,
|
|
635
|
+
containers: context.containers as EntryLike[] | undefined,
|
|
636
|
+
entities: context.entities as EntryLike[] | undefined,
|
|
637
|
+
dataProducts: context.dataProducts as EntryLike[] | undefined,
|
|
638
|
+
adrs: context.adrs as EntryLike[] | undefined,
|
|
639
|
+
channels: context.channels as EntryLike[] | undefined,
|
|
640
|
+
diagrams: context.diagrams as EntryLike[] | undefined,
|
|
641
|
+
events: context.events as EntryLike[] | undefined,
|
|
642
|
+
commands: context.commands as EntryLike[] | undefined,
|
|
643
|
+
queries: context.queries as EntryLike[] | undefined,
|
|
644
|
+
schemas: context.schemas as CustomSidebarContext['schemas'],
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Index sidebar.json entries by the folder they live in, so a resource can look up
|
|
649
|
+
* its sidebar via `path.dirname(resource.filePath)`. The lookup is folder-keyed with no
|
|
650
|
+
* collection check: every sidebar-aware resource lives in its own folder (`index.mdx`), so
|
|
651
|
+
* two resources can never share a key. Sibling files like `ubiquitous-language.mdx` and
|
|
652
|
+
* `changelog.md` are not sidebar-aware and never perform this lookup.
|
|
653
|
+
*/
|
|
654
|
+
export const indexSidebarsByFolder = (entries: CollectionEntry<'sidebars'>[]): Map<string, SidebarSpec> => {
|
|
655
|
+
const byFolder = new Map<string, SidebarSpec>();
|
|
656
|
+
for (const entry of entries) {
|
|
657
|
+
if (!entry.filePath) continue;
|
|
658
|
+
byFolder.set(path.dirname(entry.filePath), { ...(entry.data as SidebarSpec), sourcePath: entry.filePath });
|
|
659
|
+
}
|
|
660
|
+
return byFolder;
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
export const getSidebarForResource = (
|
|
664
|
+
sidebarsByFolder: Map<string, SidebarSpec>,
|
|
665
|
+
resource: { filePath?: string }
|
|
666
|
+
): SidebarSpec | undefined => {
|
|
667
|
+
if (!resource.filePath) return undefined;
|
|
668
|
+
const folder = path.dirname(resource.filePath);
|
|
669
|
+
const exact = sidebarsByFolder.get(folder);
|
|
670
|
+
if (exact) return exact;
|
|
671
|
+
|
|
672
|
+
// A versioned copy (…/OrderCreated/versioned/1.0.0/index.mdx) inherits the resource
|
|
673
|
+
// folder's sidebar.json unless its own versioned folder carries one, so both versions of
|
|
674
|
+
// a resource render the same structure. Version-specific links still resolve correctly:
|
|
675
|
+
// predefined sections are built per version and `{version}` placeholders interpolate it.
|
|
676
|
+
const versionedMatch = folder.match(/^(.*)\/versioned\/[^/]+$/);
|
|
677
|
+
return versionedMatch ? sidebarsByFolder.get(versionedMatch[1]) : undefined;
|
|
678
|
+
};
|