Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-orphaned-workspace-sidebar.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@zpress/core': patch
---

Fix multi-sidebar routing for workspace children whose paths live outside the parent prefix. When `packages` items use paths like `/libs/ai` instead of `/packages/ai`, Rspress prefix matching could not find a sidebar key — the sidebar silently disappeared. Extra sidebar keys are now emitted for orphaned child paths so they resolve correctly.
192 changes: 192 additions & 0 deletions packages/core/src/sync/sidebar/multi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { describe, it, expect } from 'vitest'

import type { ResolvedEntry } from '../types'
import { buildMultiSidebar } from './multi'

describe('buildMultiSidebar()', () => {
it('should place non-standalone entries under the root "/" key', () => {
const entries: ResolvedEntry[] = [
{
title: 'Guides',
link: '/guides',
items: [
{ title: 'Setup', link: '/guides/setup', page: { outputPath: 'guides/setup.md', frontmatter: {} } },
],
},
]

const result = buildMultiSidebar(entries, [])

expect(result['/']).toBeDefined()
expect(result['/'].length).toBeGreaterThan(0)
})

it('should create sidebar keys for standalone entries', () => {
const entries: ResolvedEntry[] = [
{
title: 'Apps',
link: '/apps',
standalone: true,
items: [
{ title: 'API', link: '/apps/api' },
{ title: 'Web', link: '/apps/web' },
],
},
]

const result = buildMultiSidebar(entries, [])

expect(result['/apps']).toBeDefined()
expect(result['/apps/']).toBeDefined()
})

it('should create orphaned child keys when children live outside parent prefix', () => {
const entries: ResolvedEntry[] = [
{
title: 'Packages',
link: '/packages',
standalone: true,
items: [
{ title: 'AI', link: '/libs/ai' },
{ title: 'Database', link: '/libs/database' },
],
},
]

const result = buildMultiSidebar(entries, [])

// Parent keys still exist
expect(result['/packages']).toBeDefined()
expect(result['/packages/']).toBeDefined()

// Orphaned child keys are created
expect(result['/libs/ai']).toBeDefined()
expect(result['/libs/ai/']).toBeDefined()
expect(result['/libs/database']).toBeDefined()
expect(result['/libs/database/']).toBeDefined()
})

it('should use the same sidebar content for orphaned keys as the parent', () => {
const entries: ResolvedEntry[] = [
{
title: 'Packages',
link: '/packages',
standalone: true,
items: [
{ title: 'AI', link: '/libs/ai' },
{ title: 'DB', link: '/libs/db' },
],
},
]

const result = buildMultiSidebar(entries, [])

expect(result['/libs/ai/']).toEqual(result['/packages/'])
expect(result['/libs/db/']).toEqual(result['/packages/'])
})

it('should not create orphaned keys for children that match the parent prefix', () => {
const entries: ResolvedEntry[] = [
{
title: 'Apps',
link: '/apps',
standalone: true,
items: [
{ title: 'API', link: '/apps/api' },
{ title: 'Web', link: '/apps/web' },
],
},
]

const result = buildMultiSidebar(entries, [])

const keys = Object.keys(result)
// Only root, /apps, and /apps/ — no extra orphaned keys
expect(keys).not.toContain('/apps/api')
expect(keys).not.toContain('/apps/api/')
expect(keys).not.toContain('/apps/web')
expect(keys).not.toContain('/apps/web/')
})

it('should handle mixed children where some match and some are orphaned', () => {
const entries: ResolvedEntry[] = [
{
title: 'Packages',
link: '/packages',
standalone: true,
items: [
{ title: 'Utils', link: '/packages/utils' },
{ title: 'AI', link: '/libs/ai' },
],
},
]

const result = buildMultiSidebar(entries, [])

// Parent keys exist
expect(result['/packages']).toBeDefined()
expect(result['/packages/']).toBeDefined()

// Only orphaned child gets extra keys
expect(result['/libs/ai']).toBeDefined()
expect(result['/libs/ai/']).toBeDefined()

// Matched child does NOT get extra keys
expect(Object.keys(result)).not.toContain('/packages/utils')
expect(Object.keys(result)).not.toContain('/packages/utils/')
})

it('should handle standalone entry with no children', () => {
const entries: ResolvedEntry[] = [
{
title: 'Packages',
link: '/packages',
standalone: true,
},
]

const result = buildMultiSidebar(entries, [])

expect(result['/packages']).toBeDefined()
expect(result['/packages/']).toBeDefined()
})

it('should sort sidebar keys by length descending', () => {
const entries: ResolvedEntry[] = [
{ title: 'Guides', link: '/guides', items: [{ title: 'Setup', link: '/guides/setup' }] },
{
title: 'Packages',
link: '/packages',
standalone: true,
items: [{ title: 'AI', link: '/libs/ai' }],
},
]

const result = buildMultiSidebar(entries, [])
const keys = Object.keys(result)

// Keys should be sorted by length descending
const lengths = keys.map((k) => k.length)
const sorted = [...lengths].sort((a, b) => b - a)

Check failure on line 170 in packages/core/src/sync/sidebar/multi.test.ts

View workflow job for this annotation

GitHub Actions / ci

eslint-plugin-unicorn(no-array-sort)

Use `Array#toSorted()` instead of `Array#sort()`.
expect(lengths).toEqual(sorted)
})

it('should include the parent landing link in orphaned sidebar content', () => {
const entries: ResolvedEntry[] = [
{
title: 'Packages',
link: '/packages',
standalone: true,
items: [
{ title: 'AI', link: '/libs/ai' },
],
},
]

const result = buildMultiSidebar(entries, [])
const sidebar = result['/libs/ai/'] as Array<{ text: string; link: string }>

Check warning on line 187 in packages/core/src/sync/sidebar/multi.test.ts

View workflow job for this annotation

GitHub Actions / ci

typescript-eslint(array-type)

Array type using 'Array<T>' is forbidden. Use 'T[]' instead.

// First item should be the parent landing link
expect(sidebar[0]).toMatchObject({ text: 'Packages', link: '/packages' })
})
})
35 changes: 35 additions & 0 deletions packages/core/src/sync/sidebar/multi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,18 @@ export function buildMultiSidebar(
return items
})

const orphanedKeys = collectOrphanedChildLinks(entry.items, entryLink).flatMap(
(childLink) =>
[
[`${childLink}/`, sidebarItems],
[childLink, sidebarItems],
] as const
)

return [
[`${entryLink}/`, sidebarItems],
[entryLink, sidebarItems],
...orphanedKeys,
] as const
})
)
Expand Down Expand Up @@ -137,6 +146,32 @@ export function buildMultiSidebar(
// Private
// ---------------------------------------------------------------------------

/**
* Collect child links that do not fall under the parent link prefix.
*
* When a standalone section at `/packages` has children at `/libs/ai`,
* those children are "orphaned" — Rspress prefix matching on `/packages/`
* will never reach them. Returns only the links that need extra sidebar keys.
*
* @private
* @param items - Optional child entries of a standalone section
* @param parentLink - The standalone parent's link
* @returns Array of child links that are outside the parent prefix
*/
function collectOrphanedChildLinks(
items: readonly ResolvedEntry[] | undefined,
parentLink: string
): readonly string[] {
if (!items) {
return []
}
const prefix = `${parentLink}/`
return items
.filter((child) => child.link !== undefined && child.link !== null)
.map((child) => child.link as string)
.filter((childLink) => !childLink.startsWith(prefix))
}

/**
* Unwrap optional entry items to a concrete array.
*
Expand Down
Loading