@eventcatalog/core 4.8.4 → 4.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/dist/analytics/analytics.cjs +1 -1
  2. package/dist/analytics/analytics.js +2 -2
  3. package/dist/analytics/log-build.cjs +1 -1
  4. package/dist/analytics/log-build.js +3 -3
  5. package/dist/{chunk-EGWBSVND.js → chunk-5XURY2JV.js} +1 -1
  6. package/dist/{chunk-FQEK3FUI.js → chunk-BDFTJVGN.js} +1 -1
  7. package/dist/{chunk-QB4W7ZUL.js → chunk-BQXEWQ5J.js} +1 -1
  8. package/dist/{chunk-Y57SL3H3.js → chunk-HFAQXAG3.js} +1 -1
  9. package/dist/{chunk-AATZMWL3.js → chunk-IC6XSXJO.js} +1 -1
  10. package/dist/constants.cjs +1 -1
  11. package/dist/constants.js +1 -1
  12. package/dist/eventcatalog.cjs +1 -1
  13. package/dist/eventcatalog.js +5 -5
  14. package/dist/generate.cjs +1 -1
  15. package/dist/generate.js +3 -3
  16. package/dist/utils/cli-logger.cjs +1 -1
  17. package/dist/utils/cli-logger.js +2 -2
  18. package/eventcatalog/src/components/Badge.astro +3 -1
  19. package/eventcatalog/src/components/FieldsExplorer/FieldNodeGraph.tsx +0 -5
  20. package/eventcatalog/src/components/MDX/Design/Design.astro +2 -0
  21. package/eventcatalog/src/components/MDX/EntityMap/EntityMap.astro +2 -0
  22. package/eventcatalog/src/components/MDX/Flow/Flow.astro +2 -0
  23. package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +5 -7
  24. package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +5 -0
  25. package/eventcatalog/src/components/MDX/SchemaViewer/schema-viewer-utils.spec.ts +3 -3
  26. package/eventcatalog/src/components/MDX/SchemaViewer/schema-viewer-utils.ts +2 -2
  27. package/eventcatalog/src/components/SchemaExplorer/useDarkMode.ts +13 -4
  28. package/eventcatalog/src/components/SideNav/NestedSideBar/index.tsx +121 -88
  29. package/eventcatalog/src/components/SideNav/NestedSideBar/storage.ts +55 -7
  30. package/eventcatalog/src/components/SideNav/NestedSideBar/utils.spec.ts +59 -15
  31. package/eventcatalog/src/components/SideNav/NestedSideBar/utils.ts +53 -7
  32. package/eventcatalog/src/components/Tables/columns/TeamsTableColumns.tsx +1 -1
  33. package/eventcatalog/src/components/Tables/columns/UserTableColumns.tsx +2 -2
  34. package/eventcatalog/src/content.config.ts +71 -1
  35. package/eventcatalog/src/enterprise/api/schemas/[collection]/[id]/[version]/index.ts +3 -3
  36. package/eventcatalog/src/enterprise/collections/resource-docs-utils.ts +19 -0
  37. package/eventcatalog/src/enterprise/fields/pages/fields.astro +2 -0
  38. package/eventcatalog/src/enterprise/tools/catalog-tools.ts +1 -1
  39. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/index.astro +1 -1
  40. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/[docType]/[docId]/[docVersion]/index.astro +16 -15
  41. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/[docType]/[docId]/index.astro +21 -17
  42. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/graphql/[filename].astro +1 -1
  43. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/index.astro +4 -4
  44. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/index.astro +16 -8
  45. package/eventcatalog/src/pages/docs/[type]/[id]/language/[dictionaryId]/index.astro +48 -65
  46. package/eventcatalog/src/pages/docs/[type]/[id]/language/index.astro +45 -18
  47. package/eventcatalog/src/pages/docs/llm/schemas.txt.ts +3 -3
  48. package/eventcatalog/src/pages/docs/teams/[id]/index.astro +1 -1
  49. package/eventcatalog/src/pages/docs/users/[id]/index.astro +1 -1
  50. package/eventcatalog/src/pages/schemas/[type]/[id]/[version]/_index.data.ts +6 -4
  51. package/eventcatalog/src/pages/schemas/[type]/[id]/[version]/index.astro +6 -2
  52. package/eventcatalog/src/pages/schemas/explorer/_index.data.ts +2 -2
  53. package/eventcatalog/src/pages/triggers/[type]/[id]/[version]/index.astro +2 -0
  54. package/eventcatalog/src/pages/visualiser/designs/[id]/index.astro +2 -0
  55. package/eventcatalog/src/stores/sidebar-store/builders/adr.ts +96 -34
  56. package/eventcatalog/src/stores/sidebar-store/builders/agent.ts +167 -75
  57. package/eventcatalog/src/stores/sidebar-store/builders/container.ts +135 -59
  58. package/eventcatalog/src/stores/sidebar-store/builders/data-product.ts +144 -58
  59. package/eventcatalog/src/stores/sidebar-store/builders/domain.ts +306 -188
  60. package/eventcatalog/src/stores/sidebar-store/builders/entity.ts +108 -39
  61. package/eventcatalog/src/stores/sidebar-store/builders/flow.ts +147 -63
  62. package/eventcatalog/src/stores/sidebar-store/builders/message.ts +213 -122
  63. package/eventcatalog/src/stores/sidebar-store/builders/service.ts +223 -127
  64. package/eventcatalog/src/stores/sidebar-store/builders/shared.ts +3 -23
  65. package/eventcatalog/src/stores/sidebar-store/builders/system.ts +187 -97
  66. package/eventcatalog/src/stores/sidebar-store/custom-sidebar.ts +678 -0
  67. package/eventcatalog/src/stores/sidebar-store/state.ts +66 -36
  68. package/eventcatalog/src/stores/theme-store.ts +6 -3
  69. package/eventcatalog/src/styles/tailwind.css +82 -0
  70. package/eventcatalog/src/utils/collections/resource-docs.ts +1 -0
  71. package/eventcatalog/src/utils/collections/schema-loader.ts +10 -8
  72. package/package.json +3 -2
@@ -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
+ };