Repository: enzonotario/vitepress-openapi (ts)

Issue:
Path based sidebar
### What would you like?

I wonder if it is possible to implement path based sidebar. For example, we have

```
/api/v1/products CRUD
/api/v1/orders CRUD
```

Sidebar would look like

```
/api/v1
  ├ products
  |  ├ PUT
  |  ├ GET
  |  ├ POST
  |  └ DELETE
  └ orders
     ├ PUT
     ├ GET
     ├ POST
     └ DELETE
```

**Note**: empty folders are collapsed


### Why is this needed?

I want to use more than one tag per endpoint. But in this case sidebar looks strange. Maybe there is a way to organize sidebar better even if there are multiple tags...

### How could it be implemented?

_No response_

### Other information

_No response_

Pull request:
feat(sidebar): Generate items by Paths
<!-- Provide a general summary of your changes in the title above. -->

# Description

Allows to generate Sidebar Items by Paths.

## Related issues/external references

- Closes #119 

## Types of changes

- New feature


Patch:
diff --git a/dev/.vitepress/config.ts b/dev/.vitepress/config.ts
index d849b49..c6e4958 100644
--- a/dev/.vitepress/config.ts
+++ b/dev/.vitepress/config.ts
@@ -32,6 +32,21 @@ export default defineConfigWithTheme({
 
         ],
       },
+      {
+        text: 'By Paths',
+        items: [
+          ...sidebar.itemsByPaths(),
+        ],
+      },
+      {
+        text: 'By Paths Flatten to depth 2',
+        items: [
+          ...sidebar.itemsByPaths({
+            depth: 2,
+            sidebarItemTemplate: (method, path) => `<span class="OASidebarItem group/oaSidebarItem"><span class="OASidebarItem-badge OAMethodBadge--${method}">${method.toUpperCase()}</span></span>`,
+          }),
+        ],
+      },
       {
         text: 'Customizations',
         items: [
diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts
index 7e2ac27..a486079 100644
--- a/docs/.vitepress/config.mts
+++ b/docs/.vitepress/config.mts
@@ -119,7 +119,23 @@ export default defineConfigWithTheme({
               }),
             ],
           },
-
+          {
+            text: 'By Paths',
+            items: [
+              ...sidebar.itemsByPaths({
+                linkPrefix: '/example/operations/',
+              }),
+            ],
+          },
+          {
+            text: 'By Paths Flatten',
+            items: [
+              ...sidebar.itemsByPaths({
+                depth: 2,
+                linkPrefix: '/example/operations/',
+              }),
+            ],
+          },
           {
             text: 'Additional',
             items: [
diff --git a/docs/layouts/sidebar.md b/docs/layouts/sidebar.md
index 3767b1c..511fcd9 100644
--- a/docs/layouts/sidebar.md
+++ b/docs/layouts/sidebar.md
@@ -30,6 +30,27 @@ module.exports = {
 }
 ```
 
+## Method Aliases
+
+By default, the methods are displayed in uppercase.
+
+You can specify aliases for the methods by passing the `methodAliases` option to the `useSidebar` composable.
+
+```ts
+const sidebar = useSidebar({ 
+    spec,
+    methodAliases: {
+        get: 'GE',
+        post: 'PO',
+        put: 'PU',
+        delete: 'DE',
+        patch: 'PA',
+        options: 'OP',
+        head: 'HE',
+    },
+})
+```
+
 ## Items by tags
 
 To generate sidebar items by tags, you can use the `itemsByTags` function. Configure your `.vitepress/config.js` as follows:
@@ -57,24 +78,48 @@ module.exports = {
 }
 ```
 
-## Method Aliases
-
-By default, the methods are displayed in uppercase.
+## Items by Paths
 
-You can specify aliases for the methods by passing the `methodAliases` option to the `useSidebar` composable.
+To generate sidebar items by paths, you can use the `itemsByPaths` function. Configure your `.vitepress/config.js` as follows:
 
 ```ts
-const sidebar = useSidebar({ 
-    spec,
-    methodAliases: {
-        get: 'GE',
-        post: 'PO',
-        put: 'PU',
-        delete: 'DE',
-        patch: 'PA',
-        options: 'OP',
-        head: 'HE',
+import { useSidebar, useOpenapi } from 'vitepress-openapi'
+import spec from '../public/openapi.json' assert { type: 'json' }
+
+const sidebar = useSidebar({ spec })
+
+module.exports = {
+    // ...
+    themeConfig: {
+        sidebar: [
+            ...sidebar.itemsByPaths({
+                /**
+                 * Optionally, you can filter paths by a prefix. Default is an empty string.
+                 */
+                startsWith: '',
+
+                /**
+                 * Optionally, you can specify if the sidebar items are collapsable. Default is true.
+                 */
+                collapsable: true,
+                
+                /**
+                 * Optionally, you can specify a depth for the sidebar items. Default is 6, which is the maximum VitePress sidebar depth.
+                 */
+                depth: 6,
+
+                /**
+                 * Optionally, you can specify a link prefix for all generated sidebar items. Default is `/operations/`.
+                 */
+                linkPrefix: '/operations/',
+
+                /**
+                 * Optionally, you can specify a template for the sidebar items. You can see the default value
+                 * in `sidebarItemTemplate` function in the `useSidebar` composable.
+                 */
+                //sidebarItemTemplate: (method: string, path: string): string => `[${method}] ${path}`,
+            }),
+        ],
     },
-})
+}
 ```
-
diff --git a/src/composables/useSidebar.ts b/src/composables/useSidebar.ts
index 5fafdeb..35faea5 100644
--- a/src/composables/useSidebar.ts
+++ b/src/composables/useSidebar.ts
@@ -1,7 +1,10 @@
 import type { OpenAPI, OpenAPIV3 } from '@scalar/openapi-types'
+import type { DefaultTheme } from 'vitepress'
 import { OpenApi } from '../lib/OpenApi'
 import { httpVerbs } from '../index'
 import { prepareOpenAPI } from '../lib/prepareOpenAPI'
+import { generateSidebarItemsByPaths } from '../lib/sidebar/generateSidebarItemsByPaths'
+import { cleanSidebarItems } from '../lib/sidebar/cleanSidebarItems'
 import { useOpenapi } from './useOpenapi'
 import { useTheme } from './useTheme'
 
@@ -27,14 +30,10 @@ interface SidebarGroupsConfig {
   linkPrefix?: string | null
 }
 
-interface SidebarItem {
-  text: string
-  link: string
-}
-
-interface SidebarGroup {
-  text: string
-  items: SidebarItem[]
+export interface OASidebarItem extends DefaultTheme.SidebarItem {
+  path?: string
+  isOperation?: boolean
+  flattenedItems?: OASidebarItem[]
 }
 
 interface OpenAPIOperation extends OpenAPIV3.OperationObject {
@@ -77,11 +76,25 @@ export function useSidebar({
       </span>`
   }
 
+  function sidebarItemTemplateForMethodPath(method: OpenAPIV3.HttpMethods, path: string): string {
+    const operation = openapi.getPaths()?.[path]?.[method] as OpenAPIOperation | undefined
+
+    if (!operation) {
+      return `[${method.toUpperCase()}] ${path}`
+    }
+
+    const { summary } = operation
+
+    const sidebarTitle = operation['x-sidebar-title'] || summary || `${method.toUpperCase()} ${path}`
+
+    return sidebarItemTemplate(method, sidebarTitle)
+  }
+
   function generateSidebarItem(
     method: OpenAPIV3.HttpMethods,
     path: string,
     itemLinkPrefix: string = linkPrefix,
-  ): SidebarItem | null {
+  ): OASidebarItem | null {
     const operation = openapi.getPaths()?.[path]?.[method] as OpenAPIOperation | undefined
 
     if (!operation) {
@@ -102,7 +115,7 @@ export function useSidebar({
     text = '',
     linkPrefix: groupLinkPrefix = linkPrefix,
     addedOperations = new Set<string>(),
-  }: SidebarGroupConfig): SidebarGroup {
+  }: SidebarGroupConfig): OASidebarItem {
     const paths = openapi.getPaths()
 
     if (!paths) {
@@ -134,7 +147,7 @@ export function useSidebar({
 
             return null
           })
-          .filter((item): item is SidebarItem => item !== null),
+          .filter((item): item is OASidebarItem => item !== null),
       )
 
     return {
@@ -146,7 +159,7 @@ export function useSidebar({
   function generateSidebarGroups({
     tags = openapi.getOperationsTags(),
     linkPrefix: groupsLinkPrefix = linkPrefix,
-  }: SidebarGroupsConfig = {}): SidebarGroup[] {
+  }: SidebarGroupsConfig = {}): OASidebarItem[] {
     if (!openapi.getPaths()) {
       return []
     }
@@ -169,7 +182,7 @@ export function useSidebar({
       addedOperations,
     })
 
-    return untaggedGroup.items.length > 0
+    return untaggedGroup.items?.length
       ? [...taggedGroups, untaggedGroup]
       : taggedGroups
   }
@@ -177,7 +190,7 @@ export function useSidebar({
   function itemsByTags({
     tags = openapi.getFilteredTags().map((tag: OpenAPIV3.TagObject) => tag.name || ''),
     linkPrefix: tagsLinkPrefix = tagLinkPrefix,
-  }: SidebarGroupsConfig = {}): SidebarItem[] {
+  }: SidebarGroupsConfig = {}): OASidebarItem[] {
     if (!openapi.getPaths() || !tags) {
       return []
     }
@@ -188,12 +201,40 @@ export function useSidebar({
     }))
   }
 
+  function itemsByPaths({
+    startsWith = '',
+    collapsable = true,
+    depth = 6,
+    sidebarItemTemplate = sidebarItemTemplateForMethodPath,
+    linkPrefix: itemLinkPrefix = linkPrefix,
+  }: {
+    startsWith?: string
+    collapsable?: boolean
+    depth?: number
+    sidebarItemTemplate?: (method: OpenAPIV3.HttpMethods, path: string) => string
+    linkPrefix?: string
+  } = {}): DefaultTheme.SidebarItem[] {
+    const paths = openapi.getPaths()
+
+    const sidebarItems = generateSidebarItemsByPaths({
+      paths,
+      startsWith,
+      collapsable,
+      depth,
+      itemLinkPrefix,
+      sidebarItemTemplate,
+    })
+
+    return cleanSidebarItems(sidebarItems) as DefaultTheme.SidebarItem[]
+  }
+
   return {
     sidebarItemTemplate,
     generateSidebarItem,
     generateSidebarGroup,
     generateSidebarGroups,
     itemsByTags,
+    itemsByPaths,
     // TODO: Add `itemsByOperations` function
   }
 }
diff --git a/src/lib/sidebar/cleanSidebarItems.ts b/src/lib/sidebar/cleanSidebarItems.ts
new file mode 100644
index 0000000..5069a95
--- /dev/null
+++ b/src/lib/sidebar/cleanSidebarItems.ts
@@ -0,0 +1,19 @@
+import type { DefaultTheme } from 'vitepress'
+import type { OASidebarItem } from '../../composables/useSidebar'
+
+export function cleanSidebarItems(result: (OASidebarItem[] | OASidebarItem)): DefaultTheme.SidebarItem[] | DefaultTheme.SidebarItem {
+  if (Array.isArray(result)) {
+    return result.map(cleanSidebarItems) as DefaultTheme.SidebarItem[]
+  }
+
+  if (typeof result === 'object') {
+    return {
+      text: result.text,
+      ...(result.collapsed === undefined ? {} : { collapsed: result.collapsed }),
+      ...(result.link ? { link: result.link } : {}),
+      ...(result.items ? { items: cleanSidebarItems(result.items) } : {}),
+    } as DefaultTheme.SidebarItem
+  }
+
+  return result
+}
diff --git a/src/lib/sidebar/ensureGroupTextSlashPrefix.ts b/src/lib/sidebar/ensureGroupTextSlashPrefix.ts
new file mode 100644
index 0000000..35177c5
--- /dev/null
+++ b/src/lib/sidebar/ensureGroupTextSlashPrefix.ts
@@ -0,0 +1,18 @@
+import type { OASidebarItem } from '../../composables/useSidebar'
+
+/**
+ * Ensures that all group text starts with a slash for consistent path-like formatting.
+ */
+export function ensureGroupTextSlashPrefix(items: OASidebarItem[]): OASidebarItem[] {
+  return items.map((item: OASidebarItem) => {
+    if (item.items) {
+      item.items = ensureGroupTextSlashPrefix(item.items)
+
+      item.text = item.text?.startsWith('/') ? item.text : `/${item.text}`
+    } else if (item.text && !item.isOperation && !item.text.startsWith('/') && !item.text.includes('<span')) {
+      item.text = `/${item.text}`
+    }
+
+    return item
+  })
+}
diff --git a/src/lib/sidebar/flatSidebarItems.ts b/src/lib/sidebar/flatSidebarItems.ts
new file mode 100644
index 0000000..c282481
--- /dev/null
+++ b/src/lib/sidebar/flatSidebarItems.ts
@@ -0,0 +1,58 @@
+import type { OASidebarItem } from '../../composables/useSidebar'
+
+export function flatSidebarItems(items: OASidebarItem[], flattenedItems: Record<string, OASidebarItem>, maxDepth: number, currentDepth: number = 1, parentText = ''): OASidebarItem[] {
+  return moveFlattenedItemsToParent(generateFlattenedItems(items, flattenedItems, maxDepth, currentDepth, parentText))
+}
+
+function generateFlattenedItems(items: OASidebarItem[], flattenedItems: Record<string, OASidebarItem>, maxDepth: number, currentDepth: number, parentText = ''): OASidebarItem[] {
+  return items.map((item) => {
+    if (item.items) {
+      const basePath = parentText + item.text
+
+      if (currentDepth + 1 > maxDepth) {
+        item.flattenedItems = Object.keys(flattenedItems)
+          .filter(path => path.startsWith(basePath))
+          .map((path) => {
+            const item = flattenedItems[path]
+
+            return {
+              ...item,
+              items: item.items?.filter((i: OASidebarItem) => i.isOperation),
+            } as OASidebarItem
+          })
+      } else {
+        item.items = generateFlattenedItems(item.items, flattenedItems, maxDepth, currentDepth + 1, basePath)
+      }
+    }
+
+    return item
+  })
+}
+
+function moveFlattenedItemsToParent(items: OASidebarItem[], parentText: string = ''): OASidebarItem[] {
+  const result: OASidebarItem[] = []
+
+  items.forEach((item) => {
+    if (item.flattenedItems) {
+      result.push(...item.flattenedItems.map((flattenedItem) => {
+        if (flattenedItem.items && item.text !== flattenedItem.text) {
+          const basePath = parentText + item.text
+
+          const remainingPath = flattenedItem.path?.replace(basePath, '')
+
+          flattenedItem.text = (item.text || '') + remainingPath
+        }
+
+        return flattenedItem
+      }))
+    } else {
+      result.push(item)
+    }
+
+    if (item.items) {
+      item.items = moveFlattenedItemsToParent(item.items, parentText + item.text)
+    }
+  })
+
+  return result
+}
diff --git a/src/lib/sidebar/generateSidebarItemsByPaths.ts b/src/lib/sidebar/generateSidebarItemsByPaths.ts
new file mode 100644
index 0000000..f3351e8
--- /dev/null
+++ b/src/lib/sidebar/generateSidebarItemsByPaths.ts
@@ -0,0 +1,108 @@
+import type { OpenAPIV3 } from '@scalar/openapi-types'
+import { httpVerbs } from '../../index'
+import type { OASidebarItem } from '../../composables/useSidebar'
+import { mergeAdjacentGroups } from './mergeAdjacentGroups'
+import { ensureGroupTextSlashPrefix } from './ensureGroupTextSlashPrefix'
+import { setItemCollapsedState } from './setItemCollapsedState'
+import { flatSidebarItems } from './flatSidebarItems'
+
+export function generateSidebarItemsByPaths({
+  paths,
+  startsWith = '',
+  collapsable = true,
+  itemLinkPrefix = '',
+  depth = 6,
+  sidebarItemTemplate,
+}: {
+  paths: OpenAPIV3.PathsObject
+  startsWith?: string
+  collapsable?: boolean
+  itemLinkPrefix?: string
+  depth?: number
+  sidebarItemTemplate?: (method: OpenAPIV3.HttpMethods, path: string) => string
+} = {
+  paths: {},
+}): OASidebarItem[] {
+  let sidebarItems: OASidebarItem[] = []
+
+  const flattenedSidebarItems: Record<string, OASidebarItem> = {}
+
+  Object.keys(paths)
+    .filter(path => path.startsWith(startsWith))
+    .forEach((path) => {
+      const segments = path.split('/').filter(Boolean)
+      let currentGroups = sidebarItems
+
+      segments.forEach((segment, index) => {
+        const isLastSegment = index === segments.length - 1
+        const fullPath = `/${segments.slice(0, index + 1).join('/')}`
+
+        const group = findOrCreateGroup(currentGroups, segment, fullPath)
+
+        if (isLastSegment) {
+          const operationsItems = httpVerbs
+            .map((method) => {
+              const operation = paths[path] ? paths[path][method] : null
+
+              if (!operation) {
+                return null
+              }
+
+              return {
+                text: sidebarItemTemplate ? sidebarItemTemplate(method as OpenAPIV3.HttpMethods, fullPath) : `[${method.toUpperCase()}] ${operation.summary}`,
+                link: `${itemLinkPrefix}${operation.operationId}`,
+                method,
+                path: fullPath,
+                isOperation: true,
+                operation,
+              } as OASidebarItem
+            })
+            .filter((item): item is OASidebarItem => item !== null)
+
+          group.items = group.items || []
+          group.items.push(...operationsItems)
+
+          flattenedSidebarItems[fullPath] = group
+        }
+
+        // Move to the next level of hierarchy.
+        currentGroups = group.items as OASidebarItem[]
+      })
+    })
+
+  sidebarItems = mergeAdjacentGroups(sidebarItems)
+
+  sidebarItems = ensureGroupTextSlashPrefix(sidebarItems)
+
+  if (collapsable) {
+    sidebarItems = setItemCollapsedState(sidebarItems)
+  }
+
+  sidebarItems = flatSidebarItems(sidebarItems, flattenedSidebarItems, depth)
+
+  return sidebarItems
+}
+
+/**
+ * Find or create a group in the current hierarchy.
+ */
+function findOrCreateGroup(
+  currentGroups: OASidebarItem[],
+  segment: string,
+  fullPath: string,
+): OASidebarItem {
+  let group = currentGroups.find(
+    g => g.text === segment && g.path === fullPath,
+  ) as OASidebarItem | undefined
+
+  if (!group) {
+    group = {
+      text: segment,
+      path: fullPath,
+      items: [],
+    }
+    currentGroups.push(group)
+  }
+
+  return group
+}
diff --git a/src/lib/sidebar/mergeAdjacentGroups.ts b/src/lib/sidebar/mergeAdjacentGroups.ts
new file mode 100644
index 0000000..c1ee5d2
--- /dev/null
+++ b/src/lib/sidebar/mergeAdjacentGroups.ts
@@ -0,0 +1,56 @@
+import type { OASidebarItem } from '../../composables/useSidebar'
+
+/**
+ * Recursively merges sidebar groups by combining groups that have only one child
+ * and no direct operation links.
+ */
+export function mergeAdjacentGroups(items: OASidebarItem[]): OASidebarItem[] {
+  if (!Array.isArray(items)) {
+    return items
+  }
+
+  return items.reduce<OASidebarItem[]>((mergedGroups, currentGroup) => {
+    // If group has no items, return it as-is.
+    if (!currentGroup.items?.length) {
+      mergedGroups.push(currentGroup)
+      return mergedGroups
+    }
+
+    // Check if the group can be merged (single child, no direct operation links).
+    const canMergeGroup
+        = currentGroup.items.length === 1
+        && !currentGroup.items.some(item => 'link' in item)
+
+    if (canMergeGroup) {
+      const childGroup = currentGroup.items[0] as OASidebarItem
+
+      // Recursively process the child group.
+      const processedChildGroup = mergeAdjacentGroups([childGroup])[0]
+
+      // Update the child group with merged information.
+      processedChildGroup.text = constructMergedGroupText(currentGroup.text as string, processedChildGroup.text as string)
+      processedChildGroup.path = processedChildGroup.path || currentGroup.path
+
+      mergedGroups.push(processedChildGroup)
+      return mergedGroups
+    }
+
+    // Recursively process nested groups.
+    currentGroup.items = mergeAdjacentGroups(currentGroup.items as OASidebarItem[])
+
+    mergedGroups.push(currentGroup)
+    return mergedGroups
+  }, [])
+}
+
+/**
+ * Constructs a merged group text by combining parent and child group texts.
+ */
+function constructMergedGroupText(parentGroupText: string, childGroupText: string): string {
+  // Ensure child text starts with a slash for consistent path-like formatting.
+  const formattedChildText = childGroupText.startsWith('/')
+    ? childGroupText
+    : `/${childGroupText}`
+
+  return parentGroupText + formattedChildText
+}
diff --git a/src/lib/sidebar/setItemCollapsedState.ts b/src/lib/sidebar/setItemCollapsedState.ts
new file mode 100644
index 0000000..6c35cc2
--- /dev/null
+++ b/src/lib/sidebar/setItemCollapsedState.ts
@@ -0,0 +1,15 @@
+import type { OASidebarItem } from '../../composables/useSidebar'
+
+export function setItemCollapsedState(items: OASidebarItem[]): OASidebarItem[] {
+  return items.map((item) => {
+    if (item.items) {
+      item.collapsed = !item.items.length
+
+      if (item.items.some((item: OASidebarItem) => 'items' in item)) {
+        item.items = setItemCollapsedState(item.items as OASidebarItem[])
+      }
+    }
+
+    return item
+  })
+}


Test patch:
diff --git a/test/composables/useSidebar.itemsByPaths.test.ts b/test/composables/useSidebar.itemsByPaths.test.ts
new file mode 100644
index 0000000..575d197
--- /dev/null
+++ b/test/composables/useSidebar.itemsByPaths.test.ts
@@ -0,0 +1,961 @@
+import { describe, expect, it } from 'vitest'
+import type { DefaultTheme } from 'vitepress'
+import { useSidebar } from '../../src/composables/useSidebar'
+import realSpec from '../../docs/public/openapi.json'
+
+function cleanResult(result: (DefaultTheme.SidebarItem | DefaultTheme.SidebarItem[])): any {
+  if (Array.isArray(result)) {
+    return result.map(cleanResult)
+  }
+
+  if (result.text && result.text.includes('<span')) {
+    delete result.text
+  }
+
+  if (result.items) {
+    result.items = cleanResult(result.items)
+  }
+
+  return result
+}
+
+describe('useSidebar itemsByPaths', () => {
+  const sidebar = useSidebar({
+    spec: {
+      openapi: '3.0.0',
+      paths: {
+        '/api/v1/1': {
+          get: {
+            operationId: 'get1',
+            responses: {
+              200: {
+                description: 'OK',
+              },
+            },
+          },
+          post: {
+            operationId: 'create1',
+            responses: {
+              201: {
+                description: 'Created',
+              },
+            },
+          },
+        },
+        '/api/v1/1/2': {
+          get: {
+            operationId: 'get12',
+            responses: {
+              200: {
+                description: 'OK',
+              },
+            },
+          },
+          put: {
+            operationId: 'update12',
+            responses: {
+              200: {
+                description: 'OK',
+              },
+            },
+          },
+          delete: {
+            operationId: 'delete12',
+            responses: {
+              200: {
+                description: 'OK',
+              },
+            },
+          },
+        },
+        '/api/v1/1/2/{x}': {
+          get: {
+            operationId: 'get12x',
+            responses: {
+              200: {
+                description: 'OK',
+              },
+            },
+          },
+          put: {
+            operationId: 'update12x',
+            responses: {
+              200: {
+                description: 'OK',
+              },
+            },
+          },
+          delete: {
+            operationId: 'delete12x',
+            responses: {
+              200: {
+                description: 'OK',
+              },
+            },
+          },
+        },
+        '/api/v1/987': {
+          get: {
+            operationId: 'get121',
+            responses: {
+              200: {
+                description: 'OK',
+              },
+            },
+          },
+          put: {
+            operationId: 'update987',
+            responses: {
+              200: {
+                description: 'OK',
+              },
+            },
+          },
+          delete: {
+            operationId: 'delete121',
+            responses: {
+              200: {
+                description: 'OK',
+              },
+            },
+          },
+        },
+      },
+    },
+  })
+
+  it('generate items by path without options', () => {
+    const result = sidebar.itemsByPaths()
+
+    expect(
+      cleanResult(result),
+    ).toEqual([
+      {
+        collapsed: false,
+        items: [
+          {
+            collapsed: false,
+            items: [
+              {
+                link: '/operations/get1',
+              },
+              {
+                link: '/operations/create1',
+              },
+              {
+                collapsed: false,
+                items: [
+                  {
+                    link: '/operations/get12',
+                  },
+                  {
+                    link: '/operations/update12',
+                  },
+                  {
+                    link: '/operations/delete12',
+                  },
+                  {
+                    collapsed: false,
+                    items: [
+                      {
+                        link: '/operations/get12x',
+                      },
+                      {
+                        link: '/operations/update12x',
+                      },
+                      {
+                        link: '/operations/delete12x',
+                      },
+                    ],
+                    text: '/{x}',
+                  },
+                ],
+                text: '/2',
+              },
+            ],
+            text: '/1',
+          },
+          {
+            collapsed: false,
+            items: [
+              {
+                link: '/operations/get121',
+              },
+              {
+                link: '/operations/update987',
+              },
+              {
+                link: '/operations/delete121',
+              },
+            ],
+            text: '/987',
+          },
+        ],
+        text: '/api/v1',
+      },
+    ])
+  })
+
+  it('generate items by path with startsWith option', () => {
+    const result = sidebar.itemsByPaths({
+      startsWith: '/api/v1',
+    })
+
+    expect(
+      cleanResult(result),
+    ).toEqual([
+      {
+        collapsed: false,
+        items: [
+          {
+            collapsed: false,
+            items: [
+              {
+                link: '/operations/get1',
+              },
+              {
+                link: '/operations/create1',
+              },
+              {
+                collapsed: false,
+                items: [
+                  {
+                    link: '/operations/get12',
+                  },
+                  {
+                    link: '/operations/update12',
+                  },
+                  {
+                    link: '/operations/delete12',
+                  },
+                  {
+                    collapsed: false,
+                    items: [
+                      {
+                        link: '/operations/get12x',
+                      },
+                      {
+                        link: '/operations/update12x',
+                      },
+                      {
+                        link: '/operations/delete12x',
+                      },
+                    ],
+                    text: '/{x}',
+                  },
+                ],
+                text: '/2',
+              },
+            ],
+            text: '/1',
+          },
+          {
+            collapsed: false,
+            items: [
+              {
+                link: '/operations/get121',
+              },
+              {
+                link: '/operations/update987',
+              },
+              {
+                link: '/operations/delete121',
+              },
+            ],
+            text: '/987',
+          },
+        ],
+        text: '/api/v1',
+      },
+    ])
+  })
+
+  it('generate items by path without collapsed option', () => {
+    const result = sidebar.itemsByPaths({
+      collapsable: false,
+    })
+
+    expect(
+      cleanResult(result),
+    ).toEqual([
+      {
+        text: '/api/v1',
+        items: [
+          {
+            text: '/1',
+            items: [
+              {
+                link: '/operations/get1',
+              },
+              {
+                link: '/operations/create1',
+              },
+              {
+                text: '/2',
+                items: [
+                  {
+                    link: '/operations/get12',
+                  },
+                  {
+                    link: '/operations/update12',
+                  },
+                  {
+                    link: '/operations/delete12',
+                  },
+                  {
+                    text: '/{x}',
+                    items: [
+                      {
+                        link: '/operations/get12x',
+                      },
+                      {
+                        link: '/operations/update12x',
+                      },
+                      {
+                        link: '/operations/delete12x',
+                      },
+                    ],
+                  },
+                ],
+              },
+            ],
+          },
+          {
+            text: '/987',
+            items: [
+              {
+                link: '/operations/get121',
+              },
+              {
+                link: '/operations/update987',
+              },
+              {
+                link: '/operations/delete121',
+              },
+            ],
+          },
+        ],
+      },
+    ])
+  })
+
+  it('generate items witn linkPrefix option', () => {
+    const result = sidebar.itemsByPaths({
+      linkPrefix: '/custom-prefix/',
+      collapsable: false,
+    })
+
+    expect(
+      cleanResult(result),
+    ).toEqual([
+      {
+        text: '/api/v1',
+        items: [
+          {
+            text: '/1',
+            items: [
+              {
+                link: '/custom-prefix/get1',
+              },
+              {
+                link: '/custom-prefix/create1',
+              },
+              {
+                text: '/2',
+                items: [
+                  {
+                    link: '/custom-prefix/get12',
+                  },
+                  {
+                    link: '/custom-prefix/update12',
+                  },
+                  {
+                    link: '/custom-prefix/delete12',
+                  },
+                  {
+                    text: '/{x}',
+                    items: [
+                      {
+                        link: '/custom-prefix/get12x',
+                      },
+                      {
+                        link: '/custom-prefix/update12x',
+                      },
+                      {
+                        link: '/custom-prefix/delete12x',
+                      },
+                    ],
+                  },
+                ],
+              },
+            ],
+          },
+          {
+            text: '/987',
+            items: [
+              {
+                link: '/custom-prefix/get121',
+              },
+              {
+                link: '/custom-prefix/update987',
+              },
+              {
+                link: '/custom-prefix/delete121',
+              },
+            ],
+          },
+        ],
+      },
+    ])
+  })
+
+  it('generate items by path with depth 4', () => {
+    const result = sidebar.itemsByPaths({
+      depth: 4,
+    })
+
+    expect(
+      cleanResult(result),
+    ).toEqual([
+      {
+        collapsed: false,
+        items: [
+          {
+            collapsed: false,
+            items: [
+              {
+                link: '/operations/get1',
+              },
+              {
+                link: '/operations/create1',
+              },
+              {
+                collapsed: false,
+                items: [
+                  {
+                    link: '/operations/get12',
+                  },
+                  {
+                    link: '/operations/update12',
+                  },
+                  {
+                    link: '/operations/delete12',
+                  },
+                  {
+                    collapsed: false,
+                    items: [
+                      {
+                        link: '/operations/get12x',
+                      },
+                      {
+                        link: '/operations/update12x',
+                      },
+                      {
+                        link: '/operations/delete12x',
+                      },
+                    ],
+                    text: '/{x}',
+                  },
+                ],
+                text: '/2',
+              },
+            ],
+            text: '/1',
+          },
+          {
+            collapsed: false,
+            items: [
+              {
+                link: '/operations/get121',
+              },
+              {
+                link: '/operations/update987',
+              },
+              {
+                link: '/operations/delete121',
+              },
+            ],
+            text: '/987',
+          },
+        ],
+        text: '/api/v1',
+      },
+    ])
+  })
+
+  it('generate items by path with depth 3', () => {
+    const result = sidebar.itemsByPaths({
+      depth: 3,
+    })
+
+    expect(
+      cleanResult(result),
+    ).toEqual([
+      {
+        collapsed: false,
+        items: [
+          {
+            collapsed: false,
+            items: [
+              {
+                link: '/operations/get1',
+              },
+              {
+                link: '/operations/create1',
+              },
+              {
+                collapsed: false,
+                items: [
+                  {
+                    link: '/operations/get12',
+                  },
+                  {
+                    link: '/operations/update12',
+                  },
+                  {
+                    link: '/operations/delete12',
+                  },
+                ],
+                text: '/2',
+              },
+              {
+                collapsed: false,
+                items: [
+                  {
+                    link: '/operations/get12x',
+                  },
+                  {
+                    link: '/operations/update12x',
+                  },
+                  {
+                    link: '/operations/delete12x',
+                  },
+                ],
+                text: '/2/{x}',
+              },
+            ],
+            text: '/1',
+          },
+          {
+            collapsed: false,
+            items: [
+              {
+                link: '/operations/get121',
+              },
+              {
+                link: '/operations/update987',
+              },
+              {
+                link: '/operations/delete121',
+              },
+            ],
+            text: '/987',
+          },
+        ],
+        text: '/api/v1',
+      },
+    ])
+  })
+
+  it('generate items by path with depth 2', () => {
+    const result = sidebar.itemsByPaths({
+      depth: 2,
+    })
+
+    expect(
+      cleanResult(result),
+    ).toEqual([
+      {
+        collapsed: false,
+        text: '/api/v1',
+        items: [
+          {
+            collapsed: false,
+            text: '/1',
+            items: [
+              {
+                link: '/operations/get1',
+              },
+              {
+                link: '/operations/create1',
+              },
+            ],
+          },
+          {
+            collapsed: false,
+            text: '/1/2',
+            items: [
+              {
+                link: '/operations/get12',
+              },
+              {
+                link: '/operations/update12',
+              },
+              {
+                link: '/operations/delete12',
+              },
+
+            ],
+          },
+          {
+            collapsed: false,
+            text: '/1/2/{x}',
+            items: [
+              {
+                link: '/operations/get12x',
+              },
+              {
+                link: '/operations/update12x',
+              },
+              {
+                link: '/operations/delete12x',
+              },
+            ],
+          },
+          {
+            collapsed: false,
+            text: '/987',
+            items: [
+              {
+                link: '/operations/get121',
+              },
+              {
+                link: '/operations/update987',
+              },
+              {
+                link: '/operations/delete121',
+              },
+            ],
+          },
+        ],
+      },
+    ])
+  })
+
+  it('generate items by path with depth 1', () => {
+    const result = sidebar.itemsByPaths({
+      depth: 1,
+    })
+
+    expect(
+      cleanResult(result),
+    ).toEqual([
+      {
+        collapsed: false,
+        text: '/api/v1/1',
+        items: [
+          {
+            link: '/operations/get1',
+          },
+          {
+            link: '/operations/create1',
+          },
+        ],
+      },
+      {
+        collapsed: false,
+        text: '/api/v1/1/2',
+        items: [
+          {
+            link: '/operations/get12',
+          },
+          {
+            link: '/operations/update12',
+          },
+          {
+            link: '/operations/delete12',
+          },
+
+        ],
+      },
+      {
+        collapsed: false,
+        text: '/api/v1/1/2/{x}',
+        items: [
+          {
+            link: '/operations/get12x',
+          },
+          {
+            link: '/operations/update12x',
+          },
+          {
+            link: '/operations/delete12x',
+          },
+        ],
+      },
+      {
+        collapsed: false,
+        text: '/api/v1/987',
+        items: [
+          {
+            link: '/operations/get121',
+          },
+          {
+            link: '/operations/update987',
+          },
+          {
+            link: '/operations/delete121',
+          },
+        ],
+      },
+    ])
+  })
+})
+
+describe('itemsByPaths with real spec', () => {
+  const sidebar = useSidebar({
+    spec: realSpec,
+  })
+
+  it('test no options', () => {
+    const result = sidebar.itemsByPaths()
+
+    expect(
+      cleanResult(result),
+    ).toEqual([
+      {
+        collapsed: false,
+        items: [
+          {
+            collapsed: false,
+            items: [
+              {
+                link: '/operations/getAllArtists',
+              },
+              {
+                link: '/operations/createArtist',
+              },
+              {
+                collapsed: false,
+                items: [
+                  {
+                    link: '/operations/getArtist',
+                  },
+                  {
+                    link: '/operations/updateArtist',
+                  },
+                  {
+                    link: '/operations/deleteArtist',
+                  },
+                  {
+                    collapsed: false,
+                    items: [
+                      {
+                        link: '/operations/getAllAlbums',
+                      },
+                      {
+                        link: '/operations/createAlbum',
+                      },
+                    ],
+                    text: '/albums',
+                  },
+                ],
+                text: '/{artistId}',
+              },
+            ],
+            text: '/artists',
+          },
+          {
+            collapsed: false,
+            items: [
+              {
+                link: '/operations/createUser',
+              },
+            ],
+            text: '/user/signup',
+          },
+        ],
+        text: '/api/v1',
+      },
+    ])
+  })
+
+  it('test depth 3', () => {
+    const result = sidebar.itemsByPaths({
+      depth: 3,
+    })
+
+    expect(
+      cleanResult(result),
+    ).toEqual([
+      {
+        collapsed: false,
+        items: [
+          {
+            collapsed: false,
+            items: [
+              {
+                link: '/operations/getAllArtists',
+              },
+              {
+                link: '/operations/createArtist',
+              },
+              {
+                collapsed: false,
+                items: [
+                  {
+                    link: '/operations/getArtist',
+                  },
+                  {
+                    link: '/operations/updateArtist',
+                  },
+                  {
+                    link: '/operations/deleteArtist',
+                  },
+                ],
+                text: '/{artistId}',
+              },
+              {
+                collapsed: false,
+                items: [
+                  {
+                    link: '/operations/getAllAlbums',
+                  },
+                  {
+                    link: '/operations/createAlbum',
+                  },
+                ],
+                text: '/{artistId}/albums',
+              },
+            ],
+            text: '/artists',
+          },
+          {
+            collapsed: false,
+            items: [
+              {
+                link: '/operations/createUser',
+              },
+            ],
+            text: '/user/signup',
+          },
+        ],
+        text: '/api/v1',
+      },
+    ])
+  })
+
+  it('test depth 2', () => {
+    const result = sidebar.itemsByPaths({
+      depth: 2,
+    })
+
+    expect(
+      cleanResult(result),
+    ).toEqual([
+      {
+        collapsed: false,
+        items: [
+          {
+            collapsed: false,
+            items: [
+              {
+                link: '/operations/getAllArtists',
+              },
+              {
+                link: '/operations/createArtist',
+              },
+            ],
+            text: '/artists',
+          },
+          {
+            collapsed: false,
+            items: [
+              {
+                link: '/operations/getArtist',
+              },
+              {
+                link: '/operations/updateArtist',
+              },
+              {
+                link: '/operations/deleteArtist',
+              },
+            ],
+            text: '/artists/{artistId}',
+          },
+          {
+            collapsed: false,
+            items: [
+              {
+                link: '/operations/getAllAlbums',
+              },
+              {
+                link: '/operations/createAlbum',
+              },
+            ],
+            text: '/artists/{artistId}/albums',
+          },
+          {
+            collapsed: false,
+            items: [
+              {
+                link: '/operations/createUser',
+              },
+            ],
+            text: '/user/signup',
+          },
+        ],
+        text: '/api/v1',
+      },
+    ])
+  })
+
+  it('test depth 1', () => {
+    const result = sidebar.itemsByPaths({
+      depth: 1,
+    })
+
+    expect(
+      cleanResult(result),
+    ).toEqual([
+      {
+        collapsed: false,
+        items: [
+          {
+            link: '/operations/getAllArtists',
+          },
+          {
+            link: '/operations/createArtist',
+          },
+        ],
+        text: '/api/v1/artists',
+      },
+      {
+        collapsed: false,
+        items: [
+          {
+            link: '/operations/getArtist',
+          },
+          {
+            link: '/operations/updateArtist',
+          },
+          {
+            link: '/operations/deleteArtist',
+          },
+        ],
+        text: '/api/v1/artists/{artistId}',
+      },
+      {
+        collapsed: false,
+        items: [
+          {
+            link: '/operations/getAllAlbums',
+          },
+          {
+            link: '/operations/createAlbum',
+          },
+        ],
+        text: '/api/v1/artists/{artistId}/albums',
+      },
+      {
+        collapsed: false,
+        items: [
+          {
+            link: '/operations/createUser',
+          },
+        ],
+        text: '/api/v1/user/signup',
+      },
+    ])
+  })
+})
diff --git a/test/lib/sidebar/ensureGroupTextSlashPrefix.test.ts b/test/lib/sidebar/ensureGroupTextSlashPrefix.test.ts
new file mode 100644
index 0000000..dda4dca
--- /dev/null
+++ b/test/lib/sidebar/ensureGroupTextSlashPrefix.test.ts
@@ -0,0 +1,97 @@
+import { describe, expect, it } from 'vitest'
+import { ensureGroupTextSlashPrefix } from '../../../src/lib/sidebar/ensureGroupTextSlashPrefix'
+
+describe('addSlashToGroups', () => {
+  it('adds a slash to the beginning of group paths', () => {
+    const groups = [
+      {
+        text: 'group1',
+        items: [],
+      },
+    ]
+    const result = ensureGroupTextSlashPrefix(groups)
+    expect(result).toEqual([
+      {
+        text: '/group1',
+        items: [],
+      },
+    ])
+  })
+
+  it('does not add a slash if the group path already starts with a slash', () => {
+    const groups = [
+      {
+        text: '/group1',
+        items: [],
+      },
+    ]
+    const result = ensureGroupTextSlashPrefix(groups)
+    expect(result).toEqual(groups)
+  })
+
+  it('adds a slash to nested group paths', () => {
+    const groups = [
+      {
+        text: 'group1',
+        items: [
+          {
+            text: 'child1',
+            items: [],
+          },
+        ],
+      },
+    ]
+    const result = ensureGroupTextSlashPrefix(groups)
+    expect(result).toEqual([
+      {
+        text: '/group1',
+        items: [
+          {
+            text: '/child1',
+            items: [],
+          },
+        ],
+      },
+    ])
+  })
+
+  it('handles groups with mixed slashes and no slashes', () => {
+    const groups = [
+      {
+        text: 'group1',
+        items: [
+          {
+            text: '/child1',
+            items: [],
+          },
+          {
+            text: 'child2',
+            items: [],
+          },
+        ],
+      },
+    ]
+    const result = ensureGroupTextSlashPrefix(groups)
+    expect(result).toEqual([
+      {
+        text: '/group1',
+        items: [
+          {
+            text: '/child1',
+            items: [],
+          },
+          {
+            text: '/child2',
+            items: [],
+          },
+        ],
+      },
+    ])
+  })
+
+  it('handles empty groups array', () => {
+    const groups = []
+    const result = ensureGroupTextSlashPrefix(groups)
+    expect(result).toEqual([])
+  })
+})
diff --git a/test/lib/sidebar/mergeAdjacentGroups.test.ts b/test/lib/sidebar/mergeAdjacentGroups.test.ts
new file mode 100644
index 0000000..ae41c4d
--- /dev/null
+++ b/test/lib/sidebar/mergeAdjacentGroups.test.ts
@@ -0,0 +1,114 @@
+import { describe, expect, it } from 'vitest'
+import { mergeAdjacentGroups } from '../../../src/lib/sidebar/mergeAdjacentGroups'
+
+describe('mergeAdjacentGroups', () => {
+  it('merges groups with a single child group and no operations', () => {
+    const groups = [
+      {
+        text: '/group1',
+        path: '/group1',
+        items: [
+          {
+            text: '/child1',
+            path: '/group1/child1',
+            items: [],
+          },
+        ],
+      },
+    ]
+    const result = mergeAdjacentGroups(groups)
+    expect(result).toEqual([
+      {
+        text: '/group1/child1',
+        path: '/group1/child1',
+        items: [],
+      },
+    ])
+  })
+
+  it('does not merge groups with multiple child groups', () => {
+    const groups = [
+      {
+        text: '/group1',
+        path: '/group1',
+        items: [
+          {
+            text: '/child1',
+            path: '/group1/child1',
+            items: [],
+          },
+          {
+            text: 'Child 2',
+            path: '/group1/child2',
+            items: [],
+          },
+        ],
+      },
+    ]
+    const result = mergeAdjacentGroups(groups)
+    expect(result).toEqual(groups)
+  })
+
+  it('does not merge groups with operations', () => {
+    const groups = [
+      {
+        text: '/group1',
+        path: '/group1',
+        items: [
+          {
+            text: '/child1',
+            path: '/child1',
+            items: [
+              {
+                link: '/operation1',
+                text: '/operation1',
+              },
+            ],
+          },
+          {
+            link: '/operation2',
+            text: '/operation2',
+          },
+        ],
+      },
+    ]
+    const result = mergeAdjacentGroups(groups)
+    expect(result).toEqual(groups)
+  })
+
+  it('merges nested groups recursively', () => {
+    const groups = [
+      {
+        text: '/group1',
+        path: '/group1',
+        items: [
+          {
+            text: '/child1',
+            path: '/child1',
+            items: [
+              {
+                text: '/grandchild1',
+                path: '/keep-this-path',
+                items: [],
+              },
+            ],
+          },
+        ],
+      },
+    ]
+    const result = mergeAdjacentGroups(groups)
+    expect(result).toEqual([
+      {
+        text: '/group1/child1/grandchild1',
+        path: '/keep-this-path',
+        items: [],
+      },
+    ])
+  })
+
+  it('handles empty groups array', () => {
+    const groups = []
+    const result = mergeAdjacentGroups(groups)
+    expect(result).toEqual([])
+  })
+})
diff --git a/test/lib/sidebar/setItemCollapsedState.test.ts b/test/lib/sidebar/setItemCollapsedState.test.ts
new file mode 100644
index 0000000..4b402a4
--- /dev/null
+++ b/test/lib/sidebar/setItemCollapsedState.test.ts
@@ -0,0 +1,142 @@
+import { describe, expect, it } from 'vitest'
+import { setItemCollapsedState } from '../../../src/lib/sidebar/setItemCollapsedState'
+
+describe('setCollapsedGroups', () => {
+  it('collapses groups with no items', () => {
+    const groups = [
+      {
+        text: 'Group 1',
+        items: [],
+      },
+    ]
+    const result = setItemCollapsedState(groups)
+    expect(result).toEqual([
+      {
+        text: 'Group 1',
+        items: [],
+        collapsed: true,
+      },
+    ])
+  })
+
+  it('does not collapse groups with items', () => {
+    const groups = [
+      {
+        text: 'Group 1',
+        items: [
+          {
+            text: 'Child 1',
+            items: [],
+          },
+        ],
+      },
+    ]
+    const result = setItemCollapsedState(groups)
+    expect(result).toEqual([
+      {
+        text: 'Group 1',
+        items: [
+          {
+            text: 'Child 1',
+            items: [],
+            collapsed: true,
+          },
+        ],
+        collapsed: false,
+      },
+    ])
+  })
+
+  it('collapses nested groups with no items', () => {
+    const groups = [
+      {
+        text: 'Group 1',
+        items: [
+          {
+            text: 'Child 1',
+            items: [
+              {
+                text: 'Grandchild 1',
+                items: [],
+              },
+            ],
+          },
+        ],
+      },
+    ]
+    const result = setItemCollapsedState(groups)
+    expect(result).toEqual([
+      {
+        text: 'Group 1',
+        items: [
+          {
+            text: 'Child 1',
+            items: [
+              {
+                text: 'Grandchild 1',
+                items: [],
+                collapsed: true,
+              },
+            ],
+            collapsed: false,
+          },
+        ],
+        collapsed: false,
+      },
+    ])
+  })
+
+  it('handles groups with mixed items and nested groups', () => {
+    const groups = [
+      {
+        text: 'Group 1',
+        items: [
+          {
+            text: 'Child 1',
+            items: [
+              {
+                text: 'Grandchild 1',
+                items: [],
+              },
+            ],
+          },
+          {
+            text: 'Child 2',
+            items: [],
+          },
+        ],
+      },
+    ]
+    const result = setItemCollapsedState(groups)
+    expect(result).toEqual([
+      {
+        text: 'Group 1',
+        items: [
+          {
+            text: 'Child 1',
+            items: [
+              {
+                text: 'Grandchild 1',
+                items: [],
+                collapsed: true,
+              },
+            ],
+            collapsed: false,
+          },
+          {
+            text: 'Child 2',
+            items: [],
+            collapsed: true,
+          },
+        ],
+        collapsed: false,
+      },
+    ])
+  })
+
+  it('handles empty groups array', () => {
+    const groups = []
+    const result = setItemCollapsedState(groups)
+    expect(result).toEqual([])
+  })
+})