Repository: enzonotario/vitepress-openapi (ts)

Issue:
Group operations into tags groups
### What would you like?

When operations with different tags are rendered on the same page, they should be grouped by tags, as it happens in Swagger.
![image](https://github.com/user-attachments/assets/1c11a735-cf63-4bfa-918c-2f2137f72d19)


### Why is this needed?

_No response_

### How could it be implemented?

_No response_

### Other information

_No response_

Pull request:
feat(Spec): group by tags
<!-- Provide a general summary of your changes in the title above. -->

# Description

Allows to group paths by tags.

## Related issues/external references

Closes #81 

## Types of changes

- New feature


Patch:
diff --git a/dev/.vitepress/theme/components/ThemeConfiguration.vue b/dev/.vitepress/theme/components/ThemeConfiguration.vue
new file mode 100644
index 0000000..f38702d
--- /dev/null
+++ b/dev/.vitepress/theme/components/ThemeConfiguration.vue
@@ -0,0 +1,49 @@
+<script setup>
+import { useTheme } from 'vitepress-openapi'
+
+const themeConfig = useTheme()
+</script>
+
+<template>
+  <div class="grid grid-cols-3 items-start gap-4 p-2 bg-muted mb-4">
+    <label for="themeConfig" class="col-span-3 text-lg font-bold">
+      Theme Configuration
+    </label>
+
+    <label for="groupByTags" class="flex items-center gap-2">
+      <input
+        id="groupByTags"
+        type="checkbox"
+        :checked="themeConfig.getSpecConfig().groupByTags.value"
+        @change="themeConfig.setSpecConfig({ groupByTags: $event.target.checked })"
+      >
+      Group by tags
+    </label>
+
+    <div class="flex flex-col">
+      <label for="collapsePaths" class="flex items-center gap-2">
+        <input
+          id="collapsePaths"
+          type="checkbox"
+          :checked="themeConfig.getSpecConfig().collapsePaths.value"
+          @change="themeConfig.setSpecConfig({ collapsePaths: $event.target.checked })"
+        >
+        Collapse paths
+
+      </label>
+      <small class="text-gray-500 dark:text-gray-400">
+        Toggle `Group by tags` to see the effect
+      </small>
+    </div>
+
+    <label for="showPathsSummary" class="flex items-center gap-2">
+      <input
+        id="showPathsSummary"
+        type="checkbox"
+        :checked="themeConfig.getSpecConfig().showPathsSummary.value"
+        @change="themeConfig.setSpecConfig({ showPathsSummary: $event.target.checked })"
+      >
+      Show paths summary
+    </label>
+  </div>
+</template>
diff --git a/dev/one-page.md b/dev/one-page.md
index 3dcfc8d..c4bd75c 100644
--- a/dev/one-page.md
+++ b/dev/one-page.md
@@ -1,15 +1,18 @@
 ---
-aside: true
+aside: false
 outline: [1, 2]
 title: vitepress-openapi
 ---
 
 <script setup lang="ts">
 import { useRoute, useData } from 'vitepress'
+import ThemeConfiguration from './.vitepress/theme/components/ThemeConfiguration.vue'
 
 const route = useRoute()
 
 const { isDark } = useData()
 </script>
 
+<ThemeConfiguration />
+
 <OASpec :isDark="isDark" />
diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts
index 229ddc9..a6104c3 100644
--- a/docs/.vitepress/config.mts
+++ b/docs/.vitepress/config.mts
@@ -156,6 +156,7 @@ export default defineConfigWithTheme({
     resolve: {
       alias: {
         'vitepress-openapi': resolve(__dirname, '../../'),
+        '@dev': resolve(__dirname, '../../dev'),
       },
     },
   },
diff --git a/docs/composables/useTheme.md b/docs/composables/useTheme.md
index 505c882..0898947 100644
--- a/docs/composables/useTheme.md
+++ b/docs/composables/useTheme.md
@@ -69,6 +69,13 @@ export default {
                 },
             },
         })
+        
+        // Set spec configuration.
+        themeConfig.setSpecConfig({
+            groupByTags: true,
+            collapsePaths: false,
+            showPathsSummary: true,
+        })
     }
 }
 ```
@@ -124,3 +131,9 @@ export default {
 | Function        | Description                  | Default Value                                               | Allowed Values                                                                                                                   |
 |-----------------|------------------------------|-------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------|
 | `setI18nConfig` | Sets the i18n configuration. | `{ locale: 'en', fallbackLocale: 'en', messages: locales }` | `{ locale: 'es' \| 'en', fallbackLocale: 'es' \| 'en', messages: Record<'es' \| 'en', Record<string, Record<string, string>>> }` |
+
+## Spec Configuration
+
+| Function        | Description                  | Default Value                                                         | Allowed Values                                                                |
+|-----------------|------------------------------|-----------------------------------------------------------------------|-------------------------------------------------------------------------------|
+| `setSpecConfig` | Sets the spec configuration. | `{ groupByTags: true, collapsePaths: false, showPathsSummary: true }` | `{ groupByTags: boolean, collapsePaths: boolean, showPathsSummary: boolean }` |
diff --git a/docs/example/one-page.md b/docs/example/one-page.md
index e413780..de5c691 100644
--- a/docs/example/one-page.md
+++ b/docs/example/one-page.md
@@ -6,8 +6,11 @@ title: vitepress-openapi
 
 <script setup lang="ts">
 import { useData } from 'vitepress'
+import ThemeConfiguration from '@dev/.vitepress/theme/components/ThemeConfiguration.vue'
 
 const { isDark } = useData()
 </script>
 
+<ThemeConfiguration />
+
 <OASpec :isDark="isDark" />
diff --git a/docs/tailwind.config.js b/docs/tailwind.config.js
index ef9e596..e25ccd5 100644
--- a/docs/tailwind.config.js
+++ b/docs/tailwind.config.js
@@ -1,5 +1,6 @@
 module.exports = {
   content: [
+    '../dev/.vitepress/theme/**/*.{js,vue,ts,json,md}',
     './.vitepress/theme/**/*.{js,vue,ts,json,md}',
     './.vitepress/config.js',
     './**/*.md',
diff --git a/src/components/Common/OAMethodBadge.vue b/src/components/Common/OAMethodBadge.vue
new file mode 100644
index 0000000..70bef4b
--- /dev/null
+++ b/src/components/Common/OAMethodBadge.vue
@@ -0,0 +1,20 @@
+<script setup>
+import { Badge } from 'vitepress-openapi/components/ui/badge'
+
+const props = defineProps({
+  method: {
+    type: String,
+    required: true,
+  },
+})
+</script>
+
+<template>
+  <Badge
+    variant="plain"
+    :class="[`OAMethodBadge--${props.method.toLowerCase()}`]"
+    class="px-2 py-1 rounded"
+  >
+    {{ props.method.toUpperCase() }}
+  </Badge>
+</template>
diff --git a/src/components/Common/OASpec.vue b/src/components/Common/OASpec.vue
index 636a8b4..f249d29 100644
--- a/src/components/Common/OASpec.vue
+++ b/src/components/Common/OASpec.vue
@@ -1,5 +1,5 @@
 <script setup>
-import { OpenApi, useOpenapi } from 'vitepress-openapi'
+import { OpenApi, useOpenapi, useTheme } from 'vitepress-openapi'
 import OAInfo from 'vitepress-openapi/components/Common/OAInfo.vue'
 import OAServers from 'vitepress-openapi/components/Common/OAServers.vue'
 
@@ -21,11 +21,21 @@ const props = defineProps({
     type: Boolean,
     default: false,
   },
+  groupByTags: {
+    type: Boolean,
+    default: null,
+  },
+  hideDefaultFooter: {
+    type: Boolean,
+    default: false,
+  },
 })
 
-const openapi = OpenApi({ spec: props.spec || useOpenapi().json })
+const themeConfig = useTheme()
+
+const spec = props.spec || useOpenapi().json
 
-const paths = openapi.getPaths()
+const openapi = OpenApi({ spec })
 
 const servers = openapi.getServers()
 
@@ -34,38 +44,27 @@ const info = openapi.getInfo()
 const showInfo = !props.hideInfo && Object.keys(info).length
 
 const showServers = !props.hideServers && servers.length
+
+const groupByTags = props.groupByTags ?? themeConfig.getSpecConfig().groupByTags
 </script>
 
 <template>
   <div class="flex flex-col space-y-10">
     <div v-if="showInfo || showServers">
-      <OAInfo v-if="showInfo" :spec="props.spec" />
+      <OAInfo v-if="showInfo" :spec="spec" />
 
-      <OAServers v-if="showServers" :spec="props.spec" :servers="servers" />
+      <OAServers v-if="showServers" :spec="spec" :servers="servers" />
     </div>
 
     <hr v-if="showInfo || showServers">
 
-    <div
-      v-for="path in paths"
-      :key="path.id"
-      class="flex flex-col space-y-10"
-    >
-      <template v-for="method in Object.keys(path)">
-        <OAOperation
-          v-if="path[method].operationId"
-          :key="`${method}-${path.id}`"
-          :operation-id="path[method].operationId"
-          :spec="props.spec"
-          :is-dark="props.isDark"
-          prefix-headings
-          hide-default-footer
-        />
-      </template>
-
-      <hr>
-    </div>
+    <template v-if="groupByTags && openapi.getOperationsTags().length">
+      <OAPathsByTags :spec="spec" :paths="openapi.getPaths()" />
+    </template>
+    <template v-else>
+      <OAPaths :spec="spec" :paths="openapi.getPaths()" />
+    </template>
 
-    <OAFooter />
+    <OAFooter v-if="!props.hideDefaultFooter" />
   </div>
 </template>
diff --git a/src/components/Path/OAPathEndpoint.vue b/src/components/Path/OAPathEndpoint.vue
index b6a6122..e14dd7d 100644
--- a/src/components/Path/OAPathEndpoint.vue
+++ b/src/components/Path/OAPathEndpoint.vue
@@ -1,5 +1,5 @@
 <script setup>
-import { Badge } from 'vitepress-openapi/components/ui/badge'
+import OAMethodBadge from 'vitepress-openapi/components/Common/OAMethodBadge.vue'
 
 const props = defineProps({
   path: {
@@ -27,13 +27,7 @@ const props = defineProps({
 
 <template>
   <div class="flex flex-row items-center space-x-4 text-sm bg-muted rounded p-2 overflow-x-auto">
-    <Badge
-      variant="plain"
-      :class="[`OAMethodBadge--${props.method.toLowerCase()}`]"
-      class="px-2 py-1 rounded"
-    >
-      {{ props.method.toUpperCase() }}
-    </Badge>
+    <OAMethodBadge :method="props.method" />
     <span class="flex flex-row flex-shrink-0 text-gray-600 dark:text-gray-400">
       <span
         v-if="!props.hideBaseUrl"
diff --git a/src/components/Path/OAPaths.vue b/src/components/Path/OAPaths.vue
new file mode 100644
index 0000000..379567a
--- /dev/null
+++ b/src/components/Path/OAPaths.vue
@@ -0,0 +1,38 @@
+<script setup>
+import { defineProps } from 'vue'
+
+const { spec, paths, isDark } = defineProps({
+  spec: {
+    type: Object,
+    required: true,
+  },
+  paths: {
+    type: Object,
+    required: true,
+  },
+  isDark: {
+    type: Boolean,
+    default: false,
+  },
+})
+</script>
+
+<template>
+  <div
+    v-for="(path, pathName) in paths"
+    :key="pathName"
+    class="flex flex-col space-y-10"
+  >
+    <OAOperation
+      v-for="method in Object.keys(path).filter(m => path[m].operationId)"
+      :key="`${method}-${path.id}`"
+      :operation-id="path[method].operationId"
+      :spec="spec"
+      :is-dark="isDark"
+      prefix-headings
+      hide-default-footer
+    />
+
+    <hr>
+  </div>
+</template>
diff --git a/src/components/Path/OAPathsByTags.vue b/src/components/Path/OAPathsByTags.vue
new file mode 100644
index 0000000..a61e1b9
--- /dev/null
+++ b/src/components/Path/OAPathsByTags.vue
@@ -0,0 +1,124 @@
+<script setup>
+import { defineProps, ref } from 'vue'
+import { Collapsible, CollapsibleTrigger } from 'vitepress-openapi/components/ui/collapsible'
+import { Button } from 'vitepress-openapi/components/ui/button'
+import { useI18n } from 'vue-i18n'
+import { OpenApi, useOpenapi, useTheme } from 'vitepress-openapi'
+import OAPathsSummary from 'vitepress-openapi/components/Path/OAPathsSummary.vue'
+
+const props = defineProps({
+  spec: {
+    type: Object,
+    required: true,
+  },
+  paths: {
+    type: Object,
+    required: true,
+  },
+  isDark: {
+    type: Boolean,
+    default: false,
+  },
+})
+
+const { t } = useI18n()
+
+const themeConfig = useTheme()
+
+const spec = props.spec || useOpenapi().json
+
+const openapi = OpenApi({ spec })
+
+const tags = openapi.getTags()
+
+const operationsTags = openapi.getOperationsTags()
+
+const pathsByTags = operationsTags.map((tag) => {
+  return {
+    tag,
+    paths: openapi.getPathsByTags(tag),
+  }
+})
+
+const internalTags = ref([
+  {
+    tag: t('Default'),
+    paths: openapi.getPathsWithoutTags(),
+    isOpen: !themeConfig.getSpecConfig().collapsePaths.value,
+  },
+  ...pathsByTags.map((tag) => {
+    return {
+      tag: tag.tag,
+      paths: tag.paths,
+      isOpen: !themeConfig.getSpecConfig().collapsePaths.value,
+    }
+  }),
+])
+
+function scrollIntoViewWithOffset(selector, offset) {
+  window.scrollTo({
+    behavior: 'smooth',
+    top:
+        document.querySelector(selector).getBoundingClientRect().top
+        - document.body.getBoundingClientRect().top
+        - offset,
+  })
+}
+
+function onPathClick(tagPaths, hash) {
+  tagPaths.isOpen = true
+
+  setTimeout(() => {
+    scrollIntoViewWithOffset(hash, 120)
+  })
+}
+</script>
+
+<template>
+  <div
+    v-for="tagPaths in internalTags"
+    :key="tagPaths.tag"
+    class="flex flex-col space-y-10"
+  >
+    <Collapsible
+      v-if="Object.keys(tagPaths.paths).length"
+      v-model:open="tagPaths.isOpen"
+    >
+      <OAHeading level="h1">
+        {{ tagPaths.tag }}
+      </OAHeading>
+
+      <div
+        :class="{ 'md:grid-cols-2': themeConfig.getSpecConfig().showPathsSummary.value }"
+        class="grid grid-cols-1 gap-10"
+      >
+        <div>
+          <p v-if="tags.find(tag => tag.name === tagPaths.tag)?.description">
+            {{ tags.find(tag => tag.name === tagPaths.tag).description }}
+          </p>
+        </div>
+
+        <div
+          v-if="themeConfig.getSpecConfig().showPathsSummary.value"
+          class="flex-1 my-[16px]"
+        >
+          <OAPathsSummary :spec="spec" :paths="tagPaths.paths" :is-dark="isDark" @path-click="onPathClick(tagPaths, $event)" />
+        </div>
+      </div>
+
+      <div class="flex justify-center">
+        <CollapsibleTrigger>
+          <Button>
+            {{ tagPaths.isOpen ? $t('Hide operations') : $t('Show operations') }}
+          </Button>
+        </CollapsibleTrigger>
+      </div>
+
+      <hr>
+
+      <div class="flex flex-col space-y-10" :class="[{ hidden: !tagPaths.isOpen }]">
+        <OAPaths :spec="spec" :paths="tagPaths.paths" />
+      </div>
+    </Collapsible>
+  </div>
+</template>
diff --git a/src/components/Path/OAPathsSummary.vue b/src/components/Path/OAPathsSummary.vue
new file mode 100644
index 0000000..28e681b
--- /dev/null
+++ b/src/components/Path/OAPathsSummary.vue
@@ -0,0 +1,56 @@
+<script setup>
+import { defineProps } from 'vue'
+
+const { paths } = defineProps({
+  spec: {
+    type: Object,
+    required: true,
+  },
+  paths: {
+    type: Object,
+    required: true,
+  },
+  isDark: {
+    type: Boolean,
+    default: false,
+  },
+})
+
+const emit = defineEmits([
+  'path-click',
+])
+</script>
+
+<template>
+  <div class="">
+    <div class="grid grid-cols-[4rem,1fr] gap-2 overflow-auto">
+      <span />
+      <OAHeading level="h3" class="!mt-0">
+        {{ $t('Operations') }}
+      </OAHeading>
+    </div>
+
+    <template
+      v-for="(path, idx) in Object.keys(paths)"
+      :key="idx"
+    >
+      <a
+        v-for="method in Object.keys(paths[path])"
+        :key="`${path}-${method}`"
+        class="grid grid-cols-[4rem,1fr] items-center gap-2 !text-foreground !no-underline cursor-pointer"
+        @click="emit('path-click', `#${paths[path][method].operationId}`)"
+      >
+        <span :class="[`text-method-${method}`]" class="text-sm text-right">
+          {{ method.toUpperCase() }}
+        </span>
+        <span
+          :class="{
+            'line-through': paths[path][method].deprecated,
+          }"
+        >
+          {{ path }}
+        </span>
+      </a>
+    </template>
+  </div>
+</template>
diff --git a/src/components/Try/OATryItButton.vue b/src/components/Try/OATryItButton.vue
index 2850157..981ab37 100644
--- a/src/components/Try/OATryItButton.vue
+++ b/src/components/Try/OATryItButton.vue
@@ -125,7 +125,7 @@ function setLoading(value) {
 
 <template>
   <div class="flex flex-col">
-    <Button @click="tryIt">
+    <Button variant="primary" @click="tryIt">
       {{ $t('Try it out') }}
     </Button>
 
diff --git a/src/components/index.ts b/src/components/index.ts
index 0d5cc39..e60df06 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -5,6 +5,8 @@ import OAFooter from './Common/OAFooter.vue'
 import OACodeBlock from './Common/OACodeBlock.vue'
 import OAMarkdown from './Common/OAMarkdown.vue'
 import OAPath from './Path/OAPath.vue'
+import OAPaths from './Path/OAPaths.vue'
+import OAPathsByTags from './Path/OAPathsByTags.vue'
 import OAPathEndpoint from './Path/OAPathEndpoint.vue'
 import OAResponses from './Response/OAResponses.vue'
 import OAResponse from './Response/OAResponse.vue'
@@ -31,6 +33,8 @@ export {
   OACodeBlock,
   OAMarkdown,
   OAPath,
+  OAPaths,
+  OAPathsByTags,
   OAPathEndpoint,
   OAResponses,
   OAResponse,
diff --git a/src/components/ui/button/index.ts b/src/components/ui/button/index.ts
index 1b00c32..c1ea0bc 100644
--- a/src/components/ui/button/index.ts
+++ b/src/components/ui/button/index.ts
@@ -7,7 +7,8 @@ export const buttonVariants = cva(
   {
     variants: {
       variant: {
-        default: 'bg-primary text-primary-foreground hover:bg-primary/90',
+        default: 'bg-muted',
+        primary: 'bg-primary text-primary-foreground hover:bg-primary/90',
         destructive:
           'bg-destructive text-destructive-foreground hover:bg-destructive/90',
         outline:
diff --git a/src/composables/useSidebar.ts b/src/composables/useSidebar.ts
index 39b0340..93e4f7f 100644
--- a/src/composables/useSidebar.ts
+++ b/src/composables/useSidebar.ts
@@ -93,7 +93,7 @@ export function useSidebar({
     tags,
     linkPrefix,
   }: GenerateSidebarGroupsOptions = {}) {
-    tags = tags || getTags()
+    tags = tags || openapi.getOperationsTags()
     linkPrefix = linkPrefix || options.linkPrefix
 
     if (!openapi.getPaths()) {
@@ -122,30 +122,10 @@ export function useSidebar({
     return groups
   }
 
-  function getTags(): string[] {
-    if (!openapi.getPaths()) {
-      return []
-    }
-
-    return Object.values(openapi.getPaths()).reduce((tags, path: any) => {
-      for (const verb of httpVerbs) {
-        if (path[verb]?.tags) {
-          path[verb].tags.forEach((tag: string) => {
-            if (!tags.includes(tag)) {
-              tags.push(tag)
-            }
-          })
-        }
-      }
-      return tags
-    }, [])
-  }
-
   return {
     sidebarItemTemplate,
     generateSidebarItem,
     generateSidebarGroup,
     generateSidebarGroups,
-    getTags,
   }
 }
diff --git a/src/composables/useTheme.ts b/src/composables/useTheme.ts
index f56ef6a..54e8aa2 100644
--- a/src/composables/useTheme.ts
+++ b/src/composables/useTheme.ts
@@ -84,6 +84,12 @@ const i18nConfig: I18nConfig = {
   messages: locales,
 }
 
+const specConfig = {
+  groupByTags: ref(true),
+  collapsePaths: ref(false),
+  showPathsSummary: ref(true),
+}
+
 export function useTheme() {
   function getLocale(): 'es' | 'en' | string {
     return getI18nConfig().locale.value
@@ -222,6 +228,24 @@ export function useTheme() {
     }
   }
 
+  function getSpecConfig() {
+    return specConfig
+  }
+
+  function setSpecConfig(config: Partial<typeof specConfig>) {
+    if (config.groupByTags !== undefined) {
+      specConfig.groupByTags.value = config.groupByTags
+    }
+
+    if (config.collapsePaths !== undefined) {
+      specConfig.collapsePaths.value = config.collapsePaths
+    }
+
+    if (config.showPathsSummary !== undefined) {
+      specConfig.showPathsSummary.value = config.showPathsSummary
+    }
+  }
+
   return {
     schemaConfig,
     securityConfig,
@@ -253,5 +277,7 @@ export function useTheme() {
     setOperationBadges,
     getI18nConfig,
     setI18nConfig,
+    getSpecConfig,
+    setSpecConfig,
   }
 }
diff --git a/src/index.ts b/src/index.ts
index daaaf85..f2156e6 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -29,6 +29,7 @@ export const theme = {
     const themeConfig = useTheme()
 
     const i18n = VueI18n.createI18n({
+      legacy: false,
       locale: themeConfig.getI18nConfig().locale.value,
       fallbackLocale: themeConfig.getI18nConfig().fallbackLocale.value,
       messages: themeConfig.getI18nConfig().messages,
diff --git a/src/lib/OpenApi.ts b/src/lib/OpenApi.ts
index c740e54..1f29d84 100644
--- a/src/lib/OpenApi.ts
+++ b/src/lib/OpenApi.ts
@@ -210,6 +210,55 @@ export function OpenApi({ spec }: { spec: any } = { spec: null }) {
     return spec?.servers ?? []
   }
 
+  function getOperationsTags() {
+    if (!spec?.paths) {
+      return []
+    }
+
+    return Object.values(spec.paths).reduce((tags, path: any) => {
+      for (const verb of httpVerbs) {
+        if (path[verb]?.tags) {
+          path[verb].tags.forEach((tag: string) => {
+            if (!tags.includes(tag)) {
+              tags.push(tag)
+            }
+          })
+        }
+      }
+      return tags
+    }, [])
+  }
+
+  function filterPaths(predicate: (operation: any) => boolean) {
+    const paths = getPaths() ?? {}
+    const output = {}
+
+    for (const [path, methods] of Object.entries(paths)) {
+      for (const verb of httpVerbs) {
+        const operation = methods[verb]
+        if (operation && predicate(operation)) {
+          output[path] = output[path] || {}
+          output[path][verb] = operation
+        }
+      }
+    }
+
+    return output
+  }
+
+  function getPathsByTags(tags: string | string[]) {
+    const tagList = typeof tags === 'string' ? [tags] : tags
+    return filterPaths(operation => operation?.tags?.some(tag => tagList.includes(tag)))
+  }
+
+  function getPathsWithoutTags() {
+    return filterPaths(operation => !operation?.tags)
+  }
+
+  function getTags() {
+    return spec?.tags ?? []
+  }
+
   return {
     spec,
     parsedSpec,
@@ -226,5 +275,9 @@ export function OpenApi({ spec }: { spec: any } = { spec: null }) {
     getInfo,
     getExternalDocs,
     getServers,
+    getOperationsTags,
+    getPathsByTags,
+    getPathsWithoutTags,
+    getTags,
   }
 }
diff --git a/src/locales/en.json b/src/locales/en.json
index 93b8780..fbe56f8 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -35,5 +35,8 @@
   "Download file": "Download file",
   "Select...": "Select...",
   "operation.badgePrefix.operationId": "ID: ",
-  "Content-Type": "Content-Type"
+  "Content-Type": "Content-Type",
+  "Hide operations": "Hide operations",
+  "Show operations": "Show operations",
+  "Operations": "Operations"
 }
diff --git a/src/locales/es.json b/src/locales/es.json
index d5bdd37..c09e94e 100644
--- a/src/locales/es.json
+++ b/src/locales/es.json
@@ -35,5 +35,8 @@
   "Download file": "Descargar archivo",
   "Select...": "Seleccionar...",
   "operation.badgePrefix.operationId": "ID: ",
-  "Content-Type": "Content-Type"
+  "Content-Type": "Content-Type",
+  "Hide operations": "Ocultar operaciones",
+  "Show operations": "Mostrar operaciones",
+  "Operations": "Operaciones"
 }
diff --git a/src/style.css b/src/style.css
index 681efcd..f5643ca 100644
--- a/src/style.css
+++ b/src/style.css
@@ -118,6 +118,28 @@
     dark:group-hover/oaSidebarItem:bg-pink-700 dark:hover:bg-pink-700;
 }
 
+.text-method-get {
+  @apply text-green-800 dark:text-green-200;
+}
+.text-method-post {
+  @apply text-blue-800 dark:text-blue-200;
+}
+.text-method-put {
+  @apply text-yellow-800 dark:text-yellow-200;
+}
+.text-method-delete {
+  @apply text-red-800 dark:text-red-200;
+}
+.text-method-patch {
+  @apply text-purple-800 dark:text-purple-200;
+}
+.text-method-options {
+  @apply text-indigo-800 dark:text-indigo-200;
+}
+.text-method-head {
+  @apply text-pink-800 dark:text-pink-200;
+}
+
 /**
   Next links
  */
diff --git a/tailwind.config.js b/tailwind.config.js
index 5a47a8e..ac6a727 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -9,6 +9,7 @@ module.exports = {
   content: [
     './index.html',
     './src/**/*.{vue,js,ts,jsx,tsx}',
+    './dev/.vitepress/theme/**/*.{js,vue,ts,json,md}',
   ],
   theme: {
     container: {


Test patch:
diff --git a/test/composables/openapi.test.ts b/test/composables/openapi.test.ts
index bc44499..2f2452d 100644
--- a/test/composables/openapi.test.ts
+++ b/test/composables/openapi.test.ts
@@ -1,7 +1,6 @@
 import { describe, expect, it } from 'vitest'
 import { OpenApi } from 'vitepress-openapi'
 import { spec } from '../testsConstants'
-import { useSidebar } from './src/composables/useSidebar'
 
 describe('openapi with spec', () => {
   const openapi = OpenApi({ spec })
@@ -27,8 +26,8 @@ describe('openapi with spec', () => {
   })
 
   it('returns the correct tags for getTags', () => {
-    const result = useSidebar().getTags()
-    expect(result).toEqual([])
+    const result = openapi.getOperationsTags()
+    expect(result).toEqual(['users'])
   })
 
   it('returns the correct security schemes for getSecuritySchemes', () => {
@@ -71,6 +70,127 @@ describe('openapi with spec', () => {
     const result = openapi.getServers()
     expect(result).toEqual(spec.servers)
   })
+
+  it('getOperationsTags returns all unique tags', () => {
+    const api = OpenApi({
+      spec: {
+        ...spec,
+        paths: {
+          '/users': {
+            get: {
+              operationId: 'getUsers',
+              tags: ['users'],
+            },
+          },
+          '/profile': {
+            get: {
+              operationId: 'getProfile',
+              tags: ['profile'],
+            },
+          },
+        },
+      },
+    })
+    const tags = api.getOperationsTags()
+    expect(tags).toEqual(['users', 'profile'])
+  })
+
+  it('getOperationsTags returns empty array if no paths', () => {
+    const api = OpenApi({ spec: { openapi: '3.0.0' } })
+    const tags = api.getOperationsTags()
+    expect(tags).toEqual([])
+  })
+
+  it('getPathsByTags returns paths with specified tags', () => {
+    const api = OpenApi({
+      spec: {
+        ...spec,
+        paths: {
+          '/users': {
+            get: {
+              operationId: 'getUsers',
+              tags: ['users'],
+            },
+          },
+          '/profile': {
+            get: {
+              operationId: 'getProfile',
+              tags: ['profile'],
+            },
+          },
+        },
+      },
+    })
+    const paths = api.getPathsByTags('users')
+    expect(paths).toMatchObject({
+      '/users': {
+        get: {
+          operationId: 'getUsers',
+          summary: 'GET /users',
+          tags: ['users'],
+        },
+      },
+    })
+  })
+
+  it('getPathsByTags returns empty object if no matching tags', () => {
+    const api = OpenApi({ spec })
+    const paths = api.getPathsByTags('nonexistent')
+    expect(paths).toMatchObject({})
+  })
+
+  it('getPathsWithoutTags returns paths without tags', () => {
+    const api = OpenApi({
+      spec: {
+        ...spec,
+        paths: {
+          '/users': {
+            get: {
+              operationId: 'getUsers',
+              tags: ['users'],
+            },
+          },
+          '/no-tags': {
+            get: {
+              operationId: 'getNoTags',
+            },
+          },
+        },
+      },
+    })
+
+    const paths = api.getPathsWithoutTags()
+
+    expect(paths).toMatchObject({
+      '/no-tags': {
+        get: {
+          operationId: 'getNoTags',
+          summary: 'GET /no-tags',
+        },
+      },
+    })
+  })
+
+  it('getPathsWithoutTags returns empty array if no paths without tags', () => {
+    const api = OpenApi({ spec: { openapi: '3.0.0', paths: {} } })
+    const paths = api.getPathsWithoutTags()
+    expect(paths).toMatchObject({})
+  })
+
+  it('getTags returns tags object', () => {
+    const api = OpenApi({ spec })
+    const tags = api.getTags()
+    expect(tags).toEqual([{
+      name: 'users',
+      description: 'Operations about users',
+    }])
+  })
+
+  it('getTags returns empty array if no tags in spec', () => {
+    const api = OpenApi({ spec: { openapi: '3.0.0', paths: {} } })
+    const tags = api.getTags()
+    expect(tags).toEqual([])
+  })
 })
 
 describe('spec with different servers for specific path', () => {
diff --git a/test/composables/useOpenapiWithNoSpec.test.ts b/test/composables/useOpenapiWithNoSpec.test.ts
index da3d6e4..714e8dc 100644
--- a/test/composables/useOpenapiWithNoSpec.test.ts
+++ b/test/composables/useOpenapiWithNoSpec.test.ts
@@ -1,6 +1,5 @@
 import { describe, expect, it } from 'vitest'
 import { OpenApi } from 'vitepress-openapi'
-import { useSidebar } from './src/composables/useSidebar'
 
 describe('useOpenapi with no spec', () => {
   const openapi = OpenApi()
@@ -26,7 +25,7 @@ describe('useOpenapi with no spec', () => {
   })
 
   it('returns empty array for getTags when paths are not defined', () => {
-    const result = useSidebar().getTags()
+    const result = openapi.getTags()
     expect(result).toEqual([])
   })
 })
diff --git a/test/composables/useSidebar.test.ts b/test/composables/useSidebar.test.ts
index 71dacaa..4399967 100644
--- a/test/composables/useSidebar.test.ts
+++ b/test/composables/useSidebar.test.ts
@@ -13,11 +13,6 @@ describe('useSidebar', () => {
       </span>`)
   })
 
-  it('returns the correct tags for getTags', () => {
-    const result = sidebar.getTags()
-    expect(result).toEqual([])
-  })
-
   it('returns the correct sidebar items for generateSidebarGroups', () => {
     const result = sidebar.generateSidebarGroups()
     expect(result).toEqual([
@@ -36,7 +31,7 @@ describe('useSidebar', () => {
             text: sidebar.sidebarItemTemplate('get', 'GET /users/{id}/pets'),
           },
         ],
-        text: '',
+        text: 'users',
       },
     ])
   })
@@ -88,7 +83,7 @@ describe('useSidebar with linkPrefix', () => {
             text: sidebar.sidebarItemTemplate('get', 'GET /users/{id}/pets'),
           },
         ],
-        text: '',
+        text: 'users',
       },
     ])
   })
@@ -111,7 +106,7 @@ describe('useSidebar with linkPrefix', () => {
             text: sidebar.sidebarItemTemplate('get', 'GET /users/{id}/pets'),
           },
         ],
-        text: '',
+        text: 'users',
       },
     ])
 
@@ -132,7 +127,7 @@ describe('useSidebar with linkPrefix', () => {
             text: sidebar.sidebarItemTemplate('get', 'GET /users/{id}/pets'),
           },
         ],
-        text: '',
+        text: 'users',
       },
     ])
   })
diff --git a/test/composables/useTheme.test.ts b/test/composables/useTheme.test.ts
index d9e7bcc..b9e66cb 100644
--- a/test/composables/useTheme.test.ts
+++ b/test/composables/useTheme.test.ts
@@ -1,4 +1,5 @@
 import { describe, expect, it } from 'vitest'
+import { unref } from 'vue'
 import { useTheme } from './src/composables/useTheme'
 
 describe('useTheme', () => {
@@ -177,4 +178,21 @@ describe('useTheme', () => {
     const result = theme.getI18nConfig()
     expect(result.locale.value).toBe('es')
   })
+
+  it('returns the spec config', () => {
+    const result = theme.getSpecConfig()
+    expect(result).toEqual({
+      groupByTags: expect.any(Object),
+      collapsePaths: expect.any(Object),
+      showPathsSummary: expect.any(Object),
+    })
+  })
+
+  it('sets spec config', () => {
+    theme.setSpecConfig({ groupByTags: false, collapsePaths: true, showPathsSummary: false })
+    const result = theme.getSpecConfig()
+    result.groupByTags.value = false
+    result.collapsePaths.value = true
+    result.showPathsSummary.value = false
+  })
 })
diff --git a/test/testsConstants.ts b/test/testsConstants.ts
index 5de48e5..ca80d48 100644
--- a/test/testsConstants.ts
+++ b/test/testsConstants.ts
@@ -36,6 +36,9 @@ export const spec = {
             bearerAuth: [],
           },
         ],
+        'tags': [
+          'users',
+        ],
       },
     },
     '/users/{id}': {
@@ -56,6 +59,9 @@ export const spec = {
             bearerAuth: [],
           },
         ],
+        tags: [
+          'users',
+        ],
       },
     },
     '/users/{id}/pets': {
@@ -71,6 +77,9 @@ export const spec = {
             },
           },
         ],
+        tags: [
+          'users',
+        ],
       },
     },
   },
@@ -105,6 +114,12 @@ export const spec = {
       },
     },
   },
+  tags: [
+    {
+      name: 'users',
+      description: 'Operations about users',
+    },
+  ],
 }
 
 export const specWithCircularRef = {