@mintlify/common 1.0.1024 → 1.0.1025

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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,567 @@
1
+ import { isEqualIgnoringLeadingSlash } from '../paths/isEqualIgnoringLeadingSlash.js';
2
+ import { decoratedNavigationConfig } from './decoratedNavigationConfig.js';
3
+ import { filterDivisions } from './filterDivisions.js';
4
+ import { getAllPathsInDecoratedNav } from './getAllPathsInDecoratedNav.js';
5
+ import { getScopedNavForPath } from './scopeNavToPath.js';
6
+ function filterWith(decoratedNav, currentPath, currentVersion, currentLanguage, currentProduct) {
7
+ return filterDivisions({
8
+ currentPath,
9
+ decoratedNav,
10
+ userGroups: new Set(),
11
+ currentVersion,
12
+ currentLanguage,
13
+ currentProduct,
14
+ shouldUseDivisionMatch: true,
15
+ cache: { dropdownCache: new Map() },
16
+ });
17
+ }
18
+ function divisionSummary(result) {
19
+ return {
20
+ tabs: result.tabs.map(({ name, isActive }) => ({ name, isActive })),
21
+ anchors: result.anchors.map(({ name, isActive }) => ({ name, isActive })),
22
+ versions: result.versions.map(({ name, isActive }) => ({ name, isActive })),
23
+ languages: result.languages.map(({ name, isActive }) => ({ name, isActive })),
24
+ dropdowns: result.dropdowns.map(({ name, isActive }) => ({ name, isActive })),
25
+ products: result.products.map(({ name, isActive }) => ({ name, isActive })),
26
+ };
27
+ }
28
+ /**
29
+ * The correctness property behind same-url division switches: for every path
30
+ * and every selectable version/language, EITHER the precomputed jump target
31
+ * navigates away (the destination's own payload takes over) OR the switch
32
+ * keeps the url — in which case the client re-renders the scoped tree with
33
+ * flipped session state, and that render must match the full nav exactly.
34
+ */
35
+ function assertStateFlipSafety(nav) {
36
+ const paths = getAllPathsInDecoratedNav(nav);
37
+ expect(paths.length).toBeGreaterThan(0);
38
+ for (const path of paths) {
39
+ const currentPath = `/${path.replace(/^\//, '')}`;
40
+ const scoped = getScopedNavForPath({ decoratedNav: nav, currentPath });
41
+ const full = filterWith(nav, currentPath, scoped.currentVersion, scoped.currentLanguage);
42
+ const rescoped = filterWith(scoped.scopedNav, currentPath, scoped.currentVersion, scoped.currentLanguage);
43
+ // the picker only offers divisions present in the chrome, so parity is
44
+ // asserted for those; full-nav maps can carry unreachable entries from
45
+ // other products' divisions that stubs rightly no longer contribute
46
+ const selectableVersions = new Set(full.versions.map(({ name }) => name));
47
+ const selectableLanguages = new Set(full.languages.map(({ language }) => language));
48
+ const selectable = (map, keys) => new Map([...map.entries()].filter(([key]) => keys.has(key)).map(([k, v]) => [k, v.href]));
49
+ expect(selectable(rescoped.firstHrefInVersion, selectableVersions), currentPath).toEqual(selectable(full.firstHrefInVersion, selectableVersions));
50
+ expect(selectable(rescoped.firstHrefInLanguage, selectableLanguages), currentPath).toEqual(selectable(full.firstHrefInLanguage, selectableLanguages));
51
+ for (const version of full.versions) {
52
+ const target = full.firstHrefInVersion.get(version.name);
53
+ const stays = target !== undefined && isEqualIgnoringLeadingSlash(target.href, currentPath);
54
+ if (!stays)
55
+ continue;
56
+ const label = `${currentPath} -> version ${version.name}`;
57
+ const flippedFull = filterWith(nav, currentPath, version.name, scoped.currentLanguage);
58
+ const flippedScoped = filterWith(scoped.scopedNav, currentPath, version.name, scoped.currentLanguage);
59
+ expect(flippedScoped.groupsOrPages, label).toEqual(flippedFull.groupsOrPages);
60
+ expect(divisionSummary(flippedScoped), label).toEqual(divisionSummary(flippedFull));
61
+ }
62
+ // deliberately unguarded (no stays check): even a flip to a stubbed,
63
+ // non-containing product must render identically, because the sidebar is
64
+ // path-driven and the selector chrome survives in product stubs
65
+ for (const product of full.products) {
66
+ const label = `${currentPath} -> product ${product.name}`;
67
+ const flippedFull = filterWith(nav, currentPath, scoped.currentVersion, scoped.currentLanguage, product.name);
68
+ const flippedScoped = filterWith(scoped.scopedNav, currentPath, scoped.currentVersion, scoped.currentLanguage, product.name);
69
+ expect(flippedScoped.groupsOrPages, label).toEqual(flippedFull.groupsOrPages);
70
+ expect(divisionSummary(flippedScoped), label).toEqual(divisionSummary(flippedFull));
71
+ }
72
+ for (const language of full.languages) {
73
+ const target = full.firstHrefInLanguage.get(language.language);
74
+ const stays = target !== undefined && isEqualIgnoringLeadingSlash(target.href, currentPath);
75
+ if (!stays)
76
+ continue;
77
+ const label = `${currentPath} -> language ${language.language}`;
78
+ const flippedFull = filterWith(nav, currentPath, scoped.currentVersion, language.language);
79
+ const flippedScoped = filterWith(scoped.scopedNav, currentPath, scoped.currentVersion, language.language);
80
+ expect(flippedScoped.groupsOrPages, label).toEqual(flippedFull.groupsOrPages);
81
+ expect(divisionSummary(flippedScoped), label).toEqual(divisionSummary(flippedFull));
82
+ }
83
+ }
84
+ }
85
+ function versionWithTabs(version, isDefault, sharedPages) {
86
+ return Object.assign(Object.assign({ version }, (isDefault ? { default: true } : {})), { tabs: [
87
+ {
88
+ tab: 'Docs',
89
+ pages: [
90
+ ...sharedPages.map((href) => ({ title: `Shared ${href}`, href })),
91
+ { title: 'Overview', href: `/docs/${version}/overview` },
92
+ { title: 'Install', href: `/docs/${version}/install` },
93
+ ],
94
+ },
95
+ {
96
+ tab: 'Reference',
97
+ pages: [
98
+ { title: 'API', href: `/docs/${version}/reference/api` },
99
+ { title: 'SQL', href: `/docs/${version}/reference/sql` },
100
+ ],
101
+ },
102
+ ] });
103
+ }
104
+ // cockroachlabs shape: products wrap version divisions, the docs home and a
105
+ // molt-style tool tab are duplicated into every version at identical hrefs,
106
+ // and a second product has no versions at all
107
+ const productSharedNav = {
108
+ products: [
109
+ {
110
+ product: 'Database',
111
+ versions: [
112
+ {
113
+ version: 'v26',
114
+ default: true,
115
+ tabs: [
116
+ {
117
+ tab: 'Docs',
118
+ pages: [
119
+ { title: 'Home', href: '/docs' },
120
+ { title: 'Overview', href: '/docs/v26/overview' },
121
+ ],
122
+ },
123
+ {
124
+ tab: 'Migrate',
125
+ pages: [
126
+ { title: 'Migration Overview', href: '/docs/molt/overview' },
127
+ { title: 'Bulk Load', href: '/docs/molt/bulk-load' },
128
+ ],
129
+ },
130
+ ],
131
+ },
132
+ {
133
+ version: 'v25',
134
+ tabs: [
135
+ {
136
+ tab: 'Docs',
137
+ pages: [
138
+ { title: 'Home', href: '/docs' },
139
+ { title: 'Overview', href: '/docs/v25/overview' },
140
+ ],
141
+ },
142
+ {
143
+ tab: 'Migrate',
144
+ pages: [
145
+ { title: 'Migration Overview', href: '/docs/molt/overview' },
146
+ { title: 'Bulk Load', href: '/docs/molt/bulk-load' },
147
+ ],
148
+ },
149
+ ],
150
+ },
151
+ ],
152
+ },
153
+ {
154
+ product: 'Cloud',
155
+ tabs: [
156
+ {
157
+ tab: 'Deploy',
158
+ pages: [
159
+ { title: 'Get Started', href: '/docs/cloud/get-started' },
160
+ { title: 'Clusters', href: '/docs/cloud/clusters' },
161
+ ],
162
+ },
163
+ ],
164
+ },
165
+ ],
166
+ };
167
+ // the shared home exists in v26 and v25 but NOT v24: switching to v24 from it
168
+ // must navigate (into v24, per the unversioned-fallback fix) instead of
169
+ // staying on a url v24 cannot render
170
+ const partialSharedNav = {
171
+ versions: [
172
+ versionWithTabs('v26', true, ['/docs']),
173
+ versionWithTabs('v25', false, ['/docs']),
174
+ versionWithTabs('v24', false, []),
175
+ ],
176
+ };
177
+ // pages that live OUTSIDE every version division: their sidebar never derives
178
+ // from a version division, so stubs are safe there — pinned by the harness
179
+ const unversionedPagesNav = {
180
+ global: {
181
+ anchors: [{ anchor: 'External', href: 'https://example.com' }],
182
+ },
183
+ tabs: [
184
+ {
185
+ tab: 'Company',
186
+ pages: [
187
+ { title: 'About', href: '/docs/about' },
188
+ { title: 'Pricing', href: '/docs/pricing' },
189
+ ],
190
+ },
191
+ {
192
+ tab: 'Product',
193
+ versions: [
194
+ versionWithTabs('v2', true, ['/docs/shared-note']),
195
+ versionWithTabs('v1', false, ['/docs/shared-note']),
196
+ ],
197
+ },
198
+ ],
199
+ };
200
+ // a page shared across language divisions at the same href — the language
201
+ // analog of the cockroachlabs home page
202
+ const languageSharedNav = {
203
+ languages: [
204
+ {
205
+ language: 'en',
206
+ groups: [
207
+ {
208
+ group: 'Guides',
209
+ pages: [
210
+ { title: 'Legal', href: '/docs/legal' },
211
+ { title: 'Overview', href: '/docs/en/overview' },
212
+ ],
213
+ },
214
+ ],
215
+ },
216
+ {
217
+ language: 'es',
218
+ groups: [
219
+ {
220
+ group: 'Guides',
221
+ pages: [
222
+ { title: 'Legal', href: '/docs/legal' },
223
+ { title: 'Overview', href: '/docs/es/overview' },
224
+ ],
225
+ },
226
+ ],
227
+ },
228
+ ],
229
+ };
230
+ // sharing at BOTH nesting levels: the home page exists in every version and,
231
+ // within each version, in every language
232
+ const nestedSharedNav = {
233
+ versions: [
234
+ {
235
+ version: 'v2',
236
+ default: true,
237
+ languages: [
238
+ {
239
+ language: 'en',
240
+ groups: [
241
+ {
242
+ group: 'Guides',
243
+ pages: [
244
+ { title: 'Home', href: '/docs' },
245
+ { title: 'Overview', href: '/docs/v2/en/overview' },
246
+ ],
247
+ },
248
+ ],
249
+ },
250
+ {
251
+ language: 'es',
252
+ groups: [
253
+ {
254
+ group: 'Guides',
255
+ pages: [
256
+ { title: 'Home', href: '/docs' },
257
+ { title: 'Overview', href: '/docs/v2/es/overview' },
258
+ ],
259
+ },
260
+ ],
261
+ },
262
+ ],
263
+ },
264
+ {
265
+ version: 'v1',
266
+ languages: [
267
+ {
268
+ language: 'en',
269
+ groups: [
270
+ {
271
+ group: 'Guides',
272
+ pages: [
273
+ { title: 'Home', href: '/docs' },
274
+ { title: 'Overview', href: '/docs/v1/en/overview' },
275
+ ],
276
+ },
277
+ ],
278
+ },
279
+ {
280
+ language: 'es',
281
+ groups: [
282
+ {
283
+ group: 'Guides',
284
+ pages: [
285
+ { title: 'Home', href: '/docs' },
286
+ { title: 'Overview', href: '/docs/v1/es/overview' },
287
+ ],
288
+ },
289
+ ],
290
+ },
291
+ ],
292
+ },
293
+ ],
294
+ };
295
+ // a tool page listed in BOTH products at the same href, plus product menus
296
+ const ambiguousProductNav = {
297
+ products: [
298
+ {
299
+ product: 'Server',
300
+ menu: [
301
+ {
302
+ item: 'Guides',
303
+ pages: [
304
+ { title: 'Install', href: '/docs/server/install' },
305
+ { title: 'Configure', href: '/docs/server/configure' },
306
+ ],
307
+ },
308
+ ],
309
+ tabs: [
310
+ {
311
+ tab: 'Docs',
312
+ pages: [
313
+ { title: 'Shared Tool', href: '/docs/shared-tool' },
314
+ { title: 'Install', href: '/docs/server/install' },
315
+ { title: 'Configure', href: '/docs/server/configure' },
316
+ ],
317
+ },
318
+ ],
319
+ },
320
+ {
321
+ product: 'Cloud',
322
+ menu: [
323
+ {
324
+ item: 'Guides',
325
+ pages: [
326
+ { title: 'Sign Up', href: '/docs/cloud/sign-up' },
327
+ { title: 'Billing', href: '/docs/cloud/billing' },
328
+ ],
329
+ },
330
+ ],
331
+ tabs: [
332
+ {
333
+ tab: 'Docs',
334
+ pages: [
335
+ { title: 'Shared Tool', href: '/docs/shared-tool' },
336
+ { title: 'Sign Up', href: '/docs/cloud/sign-up' },
337
+ { title: 'Billing', href: '/docs/cloud/billing' },
338
+ ],
339
+ },
340
+ ],
341
+ },
342
+ ],
343
+ };
344
+ // pages beside the products array, outside every product
345
+ const outsideProductsNav = {
346
+ tabs: [
347
+ {
348
+ tab: 'Company',
349
+ pages: [
350
+ { title: 'About', href: '/docs/about' },
351
+ { title: 'Careers', href: '/docs/careers' },
352
+ ],
353
+ },
354
+ {
355
+ tab: 'Product',
356
+ products: [
357
+ {
358
+ product: 'Server',
359
+ pages: [
360
+ { title: 'Install', href: '/docs/server/install' },
361
+ { title: 'Configure', href: '/docs/server/configure' },
362
+ ],
363
+ },
364
+ {
365
+ product: 'Cloud',
366
+ pages: [
367
+ { title: 'Sign Up', href: '/docs/cloud/sign-up' },
368
+ { title: 'Billing', href: '/docs/cloud/billing' },
369
+ ],
370
+ },
371
+ ],
372
+ },
373
+ ],
374
+ };
375
+ function tripleLanguage(product, version, language, shared) {
376
+ return {
377
+ language,
378
+ groups: [
379
+ {
380
+ group: 'Guides',
381
+ pages: [
382
+ ...shared.map((href) => ({ title: `Shared ${href}`, href })),
383
+ { title: 'Overview', href: `/docs/${product}/${version}/${language}/overview` },
384
+ { title: 'Install', href: `/docs/${product}/${version}/${language}/install` },
385
+ ],
386
+ },
387
+ ],
388
+ };
389
+ }
390
+ // all three axes nested: products -> versions -> languages, with the home
391
+ // page shared across every version and language of the first product
392
+ const tripleNestedNav = {
393
+ products: [
394
+ {
395
+ product: 'Server',
396
+ versions: [
397
+ {
398
+ version: 'v2',
399
+ default: true,
400
+ languages: [
401
+ tripleLanguage('server', 'v2', 'en', ['/docs', '/docs/all-shared']),
402
+ tripleLanguage('server', 'v2', 'es', ['/docs', '/docs/all-shared']),
403
+ ],
404
+ },
405
+ {
406
+ version: 'v1',
407
+ languages: [
408
+ tripleLanguage('server', 'v1', 'en', ['/docs', '/docs/all-shared']),
409
+ tripleLanguage('server', 'v1', 'es', ['/docs', '/docs/all-shared']),
410
+ ],
411
+ },
412
+ ],
413
+ },
414
+ {
415
+ product: 'Cloud',
416
+ versions: [
417
+ {
418
+ version: 'v2',
419
+ default: true,
420
+ languages: [
421
+ tripleLanguage('cloud', 'v2', 'en', ['/docs/all-shared']),
422
+ tripleLanguage('cloud', 'v2', 'es', ['/docs/all-shared']),
423
+ ],
424
+ },
425
+ ],
426
+ },
427
+ ],
428
+ };
429
+ describe('state-flip safety under scoped nav', () => {
430
+ it('holds for the versioned fixture corpus', () => {
431
+ assertStateFlipSafety(decoratedNavigationConfig);
432
+ });
433
+ it('holds for products wrapping versions with shared home and tool pages', () => {
434
+ assertStateFlipSafety(productSharedNav);
435
+ });
436
+ it('holds when the shared page exists in only some versions', () => {
437
+ assertStateFlipSafety(partialSharedNav);
438
+ });
439
+ it('holds for pages outside every version division', () => {
440
+ assertStateFlipSafety(unversionedPagesNav);
441
+ });
442
+ it('holds for pages shared across language divisions', () => {
443
+ assertStateFlipSafety(languageSharedNav);
444
+ });
445
+ it('holds for a page shared across products', () => {
446
+ assertStateFlipSafety(ambiguousProductNav);
447
+ });
448
+ it('holds for pages outside every product', () => {
449
+ assertStateFlipSafety(outsideProductsNav);
450
+ });
451
+ it('holds for pages shared at both version and language level', () => {
452
+ assertStateFlipSafety(nestedSharedNav);
453
+ });
454
+ it('holds for products wrapping versions wrapping languages', () => {
455
+ assertStateFlipSafety(tripleNestedNav);
456
+ });
457
+ });
458
+ describe('triple-nested stripping', () => {
459
+ it('strips each axis independently on an owned page', () => {
460
+ const scoped = getScopedNavForPath({
461
+ decoratedNav: tripleNestedNav,
462
+ currentPath: '/docs/server/v2/en/overview',
463
+ });
464
+ const serialized = JSON.stringify(scoped.scopedNav);
465
+ expect(serialized).toContain('/docs/server/v2/en/install');
466
+ expect(serialized).not.toContain('/docs/server/v2/es/install');
467
+ expect(serialized).not.toContain('/docs/server/v1/en/install');
468
+ expect(serialized).not.toContain('/docs/cloud/v2/en/install');
469
+ });
470
+ it('keeps every division on a page shared across products, versions, and languages at once', () => {
471
+ const scoped = getScopedNavForPath({
472
+ decoratedNav: tripleNestedNav,
473
+ currentPath: '/docs/all-shared',
474
+ });
475
+ const serialized = JSON.stringify(scoped.scopedNav);
476
+ for (const href of [
477
+ '/docs/server/v2/en/install',
478
+ '/docs/server/v2/es/install',
479
+ '/docs/server/v1/en/install',
480
+ '/docs/server/v1/es/install',
481
+ '/docs/cloud/v2/en/install',
482
+ '/docs/cloud/v2/es/install',
483
+ ]) {
484
+ expect(serialized, href).toContain(href);
485
+ }
486
+ });
487
+ it('keeps every version-language pair containing the shared home, still stubbing the other product', () => {
488
+ const scoped = getScopedNavForPath({ decoratedNav: tripleNestedNav, currentPath: '/docs' });
489
+ const serialized = JSON.stringify(scoped.scopedNav);
490
+ for (const href of [
491
+ '/docs/server/v2/en/install',
492
+ '/docs/server/v2/es/install',
493
+ '/docs/server/v1/en/install',
494
+ '/docs/server/v1/es/install',
495
+ ]) {
496
+ expect(serialized, href).toContain(href);
497
+ }
498
+ expect(serialized).toContain('"product":"Cloud"');
499
+ expect(serialized).not.toContain('/docs/cloud/v2/en/install');
500
+ });
501
+ });
502
+ describe('shared-page scoping details', () => {
503
+ it('keeps both containing versions through the product wrapper on the shared home', () => {
504
+ const scoped = getScopedNavForPath({ decoratedNav: productSharedNav, currentPath: '/docs' });
505
+ const serialized = JSON.stringify(scoped.scopedNav);
506
+ expect(serialized).toContain('/docs/v26/overview');
507
+ expect(serialized).toContain('/docs/v25/overview');
508
+ expect(serialized).toContain('/docs/cloud/get-started');
509
+ });
510
+ it('stubs the non-containing product down to selector chrome', () => {
511
+ const scoped = getScopedNavForPath({ decoratedNav: productSharedNav, currentPath: '/docs' });
512
+ const serialized = JSON.stringify(scoped.scopedNav);
513
+ expect(serialized).toContain('"product":"Cloud"');
514
+ expect(serialized).toContain('/docs/cloud/get-started');
515
+ expect(serialized).not.toContain('/docs/cloud/clusters');
516
+ });
517
+ it('keeps every product containing an ambiguous page', () => {
518
+ const scoped = getScopedNavForPath({
519
+ decoratedNav: ambiguousProductNav,
520
+ currentPath: '/docs/shared-tool',
521
+ });
522
+ const serialized = JSON.stringify(scoped.scopedNav);
523
+ expect(serialized).toContain('/docs/server/configure');
524
+ expect(serialized).toContain('/docs/cloud/billing');
525
+ });
526
+ it('preserves a stubbed product menu with one resolved page per item', () => {
527
+ const scoped = getScopedNavForPath({
528
+ decoratedNav: ambiguousProductNav,
529
+ currentPath: '/docs/server/install',
530
+ });
531
+ const serialized = JSON.stringify(scoped.scopedNav);
532
+ expect(serialized).toContain('"item":"Guides"');
533
+ expect(serialized).toContain('/docs/cloud/sign-up');
534
+ expect(serialized).not.toContain('/docs/cloud/billing');
535
+ });
536
+ it('stubs no product when the path lives outside all of them', () => {
537
+ const scoped = getScopedNavForPath({
538
+ decoratedNav: outsideProductsNav,
539
+ currentPath: '/docs/about',
540
+ });
541
+ const serialized = JSON.stringify(scoped.scopedNav);
542
+ expect(serialized).toContain('/docs/server/configure');
543
+ expect(serialized).toContain('/docs/cloud/billing');
544
+ });
545
+ it('sends the switch into the target version when the shared page is absent there', () => {
546
+ const scoped = getScopedNavForPath({ decoratedNav: partialSharedNav, currentPath: '/docs' });
547
+ const target = scoped.firstHrefInVersion.get('v24');
548
+ expect(target).toBeDefined();
549
+ expect(target === null || target === void 0 ? void 0 : target.href).not.toBe('/docs');
550
+ expect(target === null || target === void 0 ? void 0 : target.href.startsWith('/docs/v24/')).toBe(true);
551
+ });
552
+ it('stubs the absent version division on the partially shared page', () => {
553
+ const scoped = getScopedNavForPath({ decoratedNav: partialSharedNav, currentPath: '/docs' });
554
+ const serialized = JSON.stringify(scoped.scopedNav);
555
+ expect(serialized).toContain('/docs/v25/overview');
556
+ expect(serialized).not.toContain('/docs/v24/reference/sql');
557
+ });
558
+ it('still stubs everything for a page owned by a single version', () => {
559
+ const scoped = getScopedNavForPath({
560
+ decoratedNav: partialSharedNav,
561
+ currentPath: '/docs/v26/reference/api',
562
+ });
563
+ const serialized = JSON.stringify(scoped.scopedNav);
564
+ expect(serialized).not.toContain('/docs/v25/reference/sql');
565
+ expect(serialized).not.toContain('/docs/v24/reference/sql');
566
+ });
567
+ });
@@ -327,17 +327,16 @@ function scopeDivisionArray(entries, key, ctx) {
327
327
  return entries.map((entry) => (isNavNode(entry) ? scopeNode(entry, ctx) : entry));
328
328
  }
329
329
  const currentValue = key === 'versions' ? ctx.currentVersion : ctx.currentLanguage;
330
+ const containsPath = entries.map((entry) => isNavNode(entry) && divisionContainsPath(entry, ctx.currentPath));
330
331
  let activeIndex = -1;
331
332
  if (currentValue) {
332
- activeIndex = entries.findIndex((entry) => isNavNode(entry) &&
333
- divisionValue(entry, key) === currentValue &&
334
- divisionContainsPath(entry, ctx.currentPath));
333
+ activeIndex = entries.findIndex((entry, index) => isNavNode(entry) && divisionValue(entry, key) === currentValue && containsPath[index]);
335
334
  if (activeIndex === -1) {
336
335
  activeIndex = entries.findIndex((entry) => isNavNode(entry) && divisionValue(entry, key) === currentValue);
337
336
  }
338
337
  }
339
338
  if (activeIndex === -1) {
340
- activeIndex = entries.findIndex((entry) => isNavNode(entry) && divisionContainsPath(entry, ctx.currentPath));
339
+ activeIndex = containsPath.findIndex(Boolean);
341
340
  }
342
341
  if (activeIndex === -1) {
343
342
  activeIndex = entries.findIndex((entry) => isNavNode(entry) && entry.default === true);
@@ -349,9 +348,54 @@ function scopeDivisionArray(entries, key, ctx) {
349
348
  return entry;
350
349
  if (index === activeIndex)
351
350
  return scopeNode(entry, ctx);
351
+ // a shared page lives in several divisions at the same href; switching
352
+ // divisions there keeps the url, so the client re-renders from state and
353
+ // every containing division must stay a real (recursively scoped) tree —
354
+ // a stub would collapse the sidebar to its single switch-target page
355
+ if (containsPath[index])
356
+ return scopeNode(entry, ctx);
352
357
  return stubDivision(entry, key, ctx);
353
358
  });
354
359
  }
360
+ function stubProduct(product, ctx) {
361
+ if (typeof product.href === 'string')
362
+ return product;
363
+ const target = resolveVisibleTarget(product, ctx);
364
+ if (!target)
365
+ return product;
366
+ const stub = {};
367
+ for (const [entryKey, value] of Object.entries(product)) {
368
+ if (entryKey === 'menu' && Array.isArray(value)) {
369
+ // the product selector renders every product's hover menu, active or not
370
+ stub.menu = value.map((item) => stubMenuItem(item, ctx));
371
+ }
372
+ else if (!STUB_STRIPPED_KEYS.has(entryKey)) {
373
+ stub[entryKey] = value;
374
+ }
375
+ }
376
+ stub.pages = target.isRaw
377
+ ? [target.href]
378
+ : [makeStubPage(typeof product.product === 'string' ? product.product : '', target)];
379
+ return stub;
380
+ }
381
+ // the active product is client session state, but a session can only select a
382
+ // product without navigating when the current path lives inside it — so every
383
+ // product containing the path keeps its real (recursively scoped) tree and the
384
+ // rest reduce to selector chrome: metadata, menu items, and one click target
385
+ function scopeProductArray(entries, ctx) {
386
+ const containsPath = entries.map((entry) => isNavNode(entry) && divisionContainsPath(entry, ctx.currentPath));
387
+ // a path outside every product gives no signal to stub by; keep them whole
388
+ if (!containsPath.some(Boolean)) {
389
+ return entries.map((entry) => (isNavNode(entry) ? scopeNode(entry, ctx) : entry));
390
+ }
391
+ return entries.map((entry, index) => {
392
+ if (!isNavNode(entry))
393
+ return entry;
394
+ if (containsPath[index])
395
+ return scopeNode(entry, ctx);
396
+ return stubProduct(entry, ctx);
397
+ });
398
+ }
355
399
  function scopeNode(node, ctx) {
356
400
  const scoped = Object.assign({}, node);
357
401
  for (const key of CONTAINER_KEYS) {
@@ -364,6 +408,9 @@ function scopeNode(node, ctx) {
364
408
  else if (isSimpleDivisionKey(key) && value.length > 1) {
365
409
  scoped[key] = scopeSimpleDivisionArray(value, key, ctx);
366
410
  }
411
+ else if (key === 'products' && value.length > 1) {
412
+ scoped[key] = scopeProductArray(value, ctx);
413
+ }
367
414
  else if (key === 'menu' &&
368
415
  value.length > 1 &&
369
416
  (typeof scoped.tab === 'string' || typeof scoped.anchor === 'string')) {
@@ -5,7 +5,8 @@ export declare enum DivisionMatchLevel {
5
5
  PATH = 1,
6
6
  GROUP = 2,
7
7
  DIVISION = 3,
8
- NONE = 4
8
+ NONE = 4,
9
+ UNVERSIONED = 5
9
10
  }
10
11
  export type DivisionMatch = {
11
12
  href: string;