Repository: enzonotario/vitepress-openapi (ts)

Issue:
Add tags slot
### What would you like?

List of tags underneath the title

### Why is this needed?

Would be handy with tags pages, if one doesn't show tags in the sidebar (without `itemsByTags`)

### How could it be implemented?

I use following workaround

```vue
<OAOperation :operationId="operationId" :isDark="isDark" >
  <template #description="description">
    <div>
      <Badge type="info" v-for="tag in operation.tags">
        <!-- hardocde prefix, tell me if you know better way -->
        <a :href="`/tags/${tag}`">{{ tag }}</a>
      </Badge>
    </div>
  </template>
</OAOperation>
```

But would be nice to have

```ts
useTheme({
    operation: {
        slots: ['tags']
   }
})
```

### Other information

Related: it would be nice to have more compact representation of endpoints lists for tags pages. For now I use

```ts
import { useTheme } from 'vitepress-openapi'
useTheme({
    operation: {
        slots: [
            'header',
            'path',
            'description',
        ],
        cols: 2,
    },
})
```

Which looks a bit strange

Pull request description:
feat(Operation): tags slot
<!-- Provide a general summary of your changes in the title above. -->

# Description

- Adds an optional `tags` slot that shows every Operation Tag as a Badge
- Change vertical spacing between Operations Slots to make it more compact
- Fix default authorization selected scheme in Playground

## Related issues/external references

Closes #120 

## Types of changes

- Bug fix 
- New feature


Patch:
diff --git a/dev/.vitepress/theme/components/ThemeConfiguration.vue b/dev/.vitepress/theme/components/ThemeConfiguration.vue
index 0fb76c4..39ffc24 100644
--- a/dev/.vitepress/theme/components/ThemeConfiguration.vue
+++ b/dev/.vitepress/theme/components/ThemeConfiguration.vue
@@ -3,7 +3,7 @@ import { useTheme } from 'vitepress-openapi'
 import { onMounted, ref, watch } from 'vue'
 import { useUrlSearchParams } from '@vueuse/core'
 import { compressToURL, decompressFromURL } from '@amoutonbrady/lz-string'
-import { DEFAULT_OPERATION_SLOTS } from '../../../../src/composables/useTheme'
+import { DEFAULT_OPERATION_SLOTS } from '../../../../src/index'
 
 const params = useUrlSearchParams('history')
 
diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts
index d14c0b2..7e2ac27 100644
--- a/docs/.vitepress/config.mts
+++ b/docs/.vitepress/config.mts
@@ -87,6 +87,10 @@ export default defineConfigWithTheme({
                 text: 'Code Samples',
                 link: '/customizations/code-samples',
               },
+              {
+                text: 'Operation tags slot',
+                link: '/customizations/operation-tags-slot',
+              },
             ],
           },
         ],
diff --git a/docs/customizations/operation-tags-slot.md b/docs/customizations/operation-tags-slot.md
new file mode 100644
index 0000000..2a993c1
--- /dev/null
+++ b/docs/customizations/operation-tags-slot.md
@@ -0,0 +1,32 @@
+---
+aside: false
+outline: false
+---
+
+# Operation `tags` slot
+
+You can include the tags of an operation using `useTheme({ operation: { slots: [ ...DEFAULT_OPERATION_SLOTS, 'tags' ] })`.
+
+<ExampleBlock>
+
+<template #code>
+
+```markdown
+---
+aside: false
+outline: false
+title: vitepress-openapi
+---
+
+<!--@include: ./parts/operation-tags-slot-example.md-->
+```
+
+</template>
+
+<template #example>
+
+<!--@include: ./parts/operation-tags-slot-example.md-->
+
+</template>
+
+</ExampleBlock>
diff --git a/docs/customizations/parts/operation-tags-slot-example.md b/docs/customizations/parts/operation-tags-slot-example.md
new file mode 100644
index 0000000..1cc65b6
--- /dev/null
+++ b/docs/customizations/parts/operation-tags-slot-example.md
@@ -0,0 +1,17 @@
+<script setup lang="ts">
+import { useData } from 'vitepress'
+import { DEFAULT_OPERATION_SLOTS, useTheme } from 'vitepress-openapi'
+
+const { isDark } = useData()
+
+useTheme({
+    operation: {
+      slots: [
+        ...DEFAULT_OPERATION_SLOTS,
+        'tags',
+      ],
+    },
+})
+</script>
+
+<OASpec :isDark="isDark" />
diff --git a/package.json b/package.json
index 0351351..5e3548f 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "vitepress-openapi",
   "type": "module",
-  "version": "0.0.3-alpha.49",
+  "version": "0.0.3-alpha.50",
   "packageManager": "pnpm@9.1.1",
   "homepage": "https://vitepress-openapi.vercel.app/",
   "repository": {
diff --git a/src/components/Operation/OAOperation.vue b/src/components/Operation/OAOperation.vue
index d226dac..f6e044b 100644
--- a/src/components/Operation/OAOperation.vue
+++ b/src/components/Operation/OAOperation.vue
@@ -3,6 +3,7 @@ import { computed, inject, useSlots } from 'vue'
 import { getOpenApiInstance } from '../../lib/getOpenApiInstance'
 import OAHeaderBadges from '../Common/OAHeaderBadges.vue'
 import type { OperationSlot } from '../../types'
+import OAOperationTags from './OAOperationTags.vue'
 
 const props = defineProps({
   operationId: {
@@ -110,6 +111,24 @@ function hasSlot(name: OperationSlot): boolean {
       </div>
     </template>
 
+    <template
+      v-if="hasSlot('tags')"
+      #tags="tags"
+    >
+      <slot
+        name="tags"
+        v-bind="tags"
+      />
+    </template>
+    <template
+      v-else
+      #tags="tags"
+    >
+      <OAOperationTags
+        :tags="tags.operation.tags"
+      />
+    </template>
+
     <template
       v-if="hasSlot('description')"
       #description="description"
diff --git a/src/components/Operation/OAOperationTags.vue b/src/components/Operation/OAOperationTags.vue
new file mode 100644
index 0000000..e291757
--- /dev/null
+++ b/src/components/Operation/OAOperationTags.vue
@@ -0,0 +1,30 @@
+<script setup lang="ts">
+import { Badge } from '../ui/badge'
+import { useTheme } from '../../composables/useTheme'
+
+const props = defineProps({
+  tags: {
+    type: Array,
+    required: true,
+  },
+})
+
+const themeConfig = useTheme()
+
+const prefix = themeConfig.getTagsLinkPrefix()
+</script>
+
+<template>
+  <div class="flex flex-wrap gap-2">
+    <a
+      v-for="(tag, index) in props.tags"
+      :key="index"
+      :href="`${prefix}${tag}`"
+      :aria-label="$t('tags.goTo', { tag })"
+    >
+      <Badge variant="outline">
+        {{ tag }}
+      </Badge>
+    </a>
+  </div>
+</template>
diff --git a/src/components/Path/OAPath.vue b/src/components/Path/OAPath.vue
index 3821128..44b65f0 100644
--- a/src/components/Path/OAPath.vue
+++ b/src/components/Path/OAPath.vue
@@ -70,7 +70,7 @@ function updateRequest(newRequest) {
   <div>
     <div
       v-if="operation"
-      class="OAPath flex flex-col space-y-8"
+      class="OAPath flex flex-col space-y-4"
     >
       <template v-if="operationSlots.includes('header')">
         <slot
@@ -83,6 +83,17 @@ function updateRequest(newRequest) {
         />
       </template>
 
+      <template v-if="operationSlots.includes('tags')">
+        <slot
+          name="tags"
+          :operation="operation"
+          :method="operationMethod"
+          :base-url="baseUrl"
+          :path="operationPath"
+          :tags="operation.tags"
+        />
+      </template>
+
       <div
         :class="{
           'sm:grid-cols-1': operationCols === 1,
diff --git a/src/components/Playground/OAPlaygroundParameters.vue b/src/components/Playground/OAPlaygroundParameters.vue
index 350ea4f..e1460e7 100644
--- a/src/components/Playground/OAPlaygroundParameters.vue
+++ b/src/components/Playground/OAPlaygroundParameters.vue
@@ -86,7 +86,17 @@ const selectedSchemeName = computed(() => {
 })
 
 const selectedScheme = computed(() => {
-  return props.securitySchemes[selectedSchemeName.value]
+  const scheme = props.securitySchemes[selectedSchemeName.value]
+
+  if (!scheme) {
+    if (!Object.keys(props.securitySchemes).length) {
+      return null
+    }
+
+    return props.securitySchemes[Object.keys(props.securitySchemes)[0]]
+  }
+
+  return scheme
 })
 
 const authScheme = ref<PlaygroundSecurityScheme | null>(null)
diff --git a/src/composables/useTheme.ts b/src/composables/useTheme.ts
index 372501b..02029f7 100644
--- a/src/composables/useTheme.ts
+++ b/src/composables/useTheme.ts
@@ -103,6 +103,7 @@ export interface UseThemeConfig {
   i18n?: Partial<I18nConfig>
   spec?: Partial<SpecConfig>
   codeSamples?: Partial<CodeSamplesConfig>
+  linksPrefixes?: Partial<LinksPrefixesConfig>
 }
 
 export interface UseThemeConfigUnref {
@@ -144,6 +145,7 @@ export interface UseThemeConfigUnref {
   i18n?: Partial<I18nConfig>
   spec?: Partial<SpecConfig>
   codeSamples?: Partial<CodeSamplesConfig>
+  linksPrefixes?: Partial<LinksPrefixesConfig>
 }
 
 export interface CodeSamplesConfig {
@@ -154,6 +156,11 @@ export interface CodeSamplesConfig {
   defaultHeaders: Record<string, string>
 }
 
+export interface LinksPrefixesConfig {
+  tags: string
+  operations: string
+}
+
 interface LanguageConfig {
   lang: string
   label: string
@@ -275,12 +282,18 @@ const themeConfig: UseThemeConfig = {
       'Content-Type': 'application/json',
     },
   },
+  linksPrefixes: {
+    tags: '/tags/',
+    operations: '/operations/',
+  },
 }
 
 const defaultThemeConfig: UseThemeConfigUnref = { ...deepUnref(themeConfig) } as UseThemeConfigUnref
 
-export function useTheme(config: Partial<UseThemeConfigUnref> = {}) {
-  if (Object.keys(config).length) {
+export function useTheme(initialConfig: Partial<UseThemeConfigUnref> = {}) {
+  setConfig(initialConfig)
+
+  function setConfig(config: Partial<UseThemeConfigUnref>) {
     if (config?.theme?.highlighterTheme) {
       // @ts-expect-error: This is a valid expression.
       themeConfig.theme.highlighterTheme = {
@@ -364,10 +377,14 @@ export function useTheme(config: Partial<UseThemeConfigUnref> = {}) {
     if (config?.codeSamples !== undefined) {
       setCodeSamplesConfig(config.codeSamples)
     }
+
+    if (config?.linksPrefixes !== undefined) {
+      setLinksPrefixesConfig(config.linksPrefixes)
+    }
   }
 
   function reset() {
-    useTheme({ ...defaultThemeConfig })
+    setConfig(defaultThemeConfig)
   }
 
   function getState() {
@@ -671,6 +688,28 @@ export function useTheme(config: Partial<UseThemeConfigUnref> = {}) {
     }
   }
 
+  function getLinksPrefixesConfig() {
+    return themeConfig.linksPrefixes
+  }
+
+  function setLinksPrefixesConfig(config: Partial<LinksPrefixesConfig>) {
+    if (config.tags) {
+      (themeConfig.linksPrefixes as LinksPrefixesConfig).tags = config.tags
+    }
+
+    if (config.operations) {
+      (themeConfig.linksPrefixes as LinksPrefixesConfig).operations = config.operations
+    }
+  }
+
+  function getTagsLinkPrefix() {
+    return themeConfig?.linksPrefixes?.tags
+  }
+
+  function getOperationsLinkPrefix() {
+    return themeConfig?.linksPrefixes?.operations
+  }
+
   return {
     schemaConfig: themeConfig.requestBody,
     reset,
@@ -721,5 +760,9 @@ export function useTheme(config: Partial<UseThemeConfigUnref> = {}) {
     getCodeSamplesGenerator,
     getCodeSamplesDefaultHeaders,
     setCodeSamplesConfig,
+    getLinksPrefixesConfig,
+    setLinksPrefixesConfig,
+    getTagsLinkPrefix,
+    getOperationsLinkPrefix,
   }
 }
diff --git a/src/index.ts b/src/index.ts
index 24e1c43..3cfd37b 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -2,7 +2,7 @@ import * as VueI18n from 'vue-i18n'
 import type { EnhanceAppContext, Theme } from 'vitepress/client'
 import type { Component } from 'vue'
 import type { Awaitable } from 'vitepress'
-import { useTheme } from './composables/useTheme'
+import { DEFAULT_OPERATION_SLOTS, useTheme } from './composables/useTheme'
 import { useShiki } from './composables/useShiki'
 import * as components from './components'
 
@@ -12,6 +12,7 @@ import 'tailwindcss/tailwind.css'
 import './style.css'
 import './json.css'
 
+export { DEFAULT_OPERATION_SLOTS }
 export { useSidebar } from './composables/useSidebar'
 export { useOpenapi } from './composables/useOpenapi'
 export { useTheme } from './composables/useTheme'
diff --git a/src/locales/en.json b/src/locales/en.json
index 1cebd8b..e8a2baa 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -40,5 +40,6 @@
   "Show operations": "Show operations",
   "Operations": "Operations",
   "Default": "Default",
-  "Example": "Example"
+  "Example": "Example",
+  "tags.goTo": "View {tag} operations"
 }
diff --git a/src/locales/es.json b/src/locales/es.json
index e5d8878..7e85514 100644
--- a/src/locales/es.json
+++ b/src/locales/es.json
@@ -40,5 +40,6 @@
   "Show operations": "Mostrar operaciones",
   "Operations": "Operaciones",
   "Default": "Por defecto",
-  "Example": "Ejemplo"
+  "Example": "Ejemplo",
+  "tags.goTo": "Ver operaciones de {tag}"
 }
diff --git a/src/types.ts b/src/types.ts
index 761be15..d721479 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -6,6 +6,7 @@ import type { RemovableRef } from '@vueuse/core'
  */
 export type OperationSlot =
   | 'header'
+  | 'tags'
   | 'path'
   | 'description'
   | 'security'
@@ -18,7 +19,6 @@ export type OperationSlot =
   | 'footer'
   // TODO: Implement these slots.
   // | 'summary'
-  // | 'tags'
   // | 'servers'
 
 export type ParsedOpenAPI = OpenAPI.Document & {


Test patch:
diff --git a/test/composables/useTheme.test.ts b/test/composables/useTheme.test.ts
index 2ff7453..4eae78e 100644
--- a/test/composables/useTheme.test.ts
+++ b/test/composables/useTheme.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it } from 'vitest'
+import { beforeEach, describe, expect, it } from 'vitest'
 import { ref } from 'vue'
 import { useTheme } from '../../src/composables/useTheme'
 
@@ -148,6 +148,10 @@ describe('composition API', () => {
 describe('useTheme', () => {
   const themeConfig = useTheme()
 
+  beforeEach(() => {
+    themeConfig.reset()
+  })
+
   it('returns the default locale', () => {
     const result = themeConfig.getLocale()
     expect(result).toBe('en')
@@ -350,4 +354,46 @@ describe('useTheme', () => {
     result.avoidCirculars.value = true
     result.lazyRendering.value = true
   })
+
+  it('returns the default links prefixes config', () => {
+    const result = themeConfig.getLinksPrefixesConfig()
+    expect(result).toEqual({
+      tags: '/tags/',
+      operations: '/operations/',
+    })
+  })
+
+  it('sets and gets the links prefixes config', () => {
+    themeConfig.setLinksPrefixesConfig({
+      tags: '/new-tags/',
+      operations: '/new-operations/',
+    })
+    const result = themeConfig.getLinksPrefixesConfig()
+    expect(result).toEqual({
+      tags: '/new-tags/',
+      operations: '/new-operations/',
+    })
+  })
+
+  it('returns the default tags link prefix', () => {
+    const result = themeConfig.getTagsLinkPrefix()
+    expect(result).toBe('/tags/')
+  })
+
+  it('returns the default operations link prefix', () => {
+    const result = themeConfig.getOperationsLinkPrefix()
+    expect(result).toBe('/operations/')
+  })
+
+  it('sets and gets the tags link prefix', () => {
+    themeConfig.setLinksPrefixesConfig({ tags: '/new-tags/' })
+    const result = themeConfig.getTagsLinkPrefix()
+    expect(result).toBe('/new-tags/')
+  })
+
+  it('sets and gets the operations link prefix', () => {
+    themeConfig.setLinksPrefixesConfig({ operations: '/new-operations/' })
+    const result = themeConfig.getOperationsLinkPrefix()
+    expect(result).toBe('/new-operations/')
+  })
 })