Repository: enzonotario/vitepress-openapi (ts)

Issue:
Multilenguaje 
Seria interesante que haya una documentación que logre adaptarse al lenguaje seleccionado desde un selector. Ya dependería de uno agregar algún lenguaje adicional en nuestra docs del proyecto. 

Pull request:
feat(i18n): dynamic locale and OALocaleSelect component
<!-- Provide a general summary of your changes in the title above. -->

# Description

- Allows to change Locale dynamically
- New `OALocaleSelect` component 

## Related issues/external references

- Closes #128 

## Types of changes

- New feature


Patch:
diff --git a/dev/.vitepress/config.ts b/dev/.vitepress/config.ts
index c6e4958..5f56eef 100644
--- a/dev/.vitepress/config.ts
+++ b/dev/.vitepress/config.ts
@@ -94,6 +94,11 @@ export default defineConfigWithTheme({
         ],
       },
     ],
+    nav: [
+      {
+        component: 'OALocaleSelect',
+      },
+    ],
   },
   vite: {
     resolve: {
diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts
index a486079..2cd264f 100644
--- a/docs/.vitepress/config.mts
+++ b/docs/.vitepress/config.mts
@@ -13,7 +13,11 @@ export default defineConfigWithTheme({
   title: 'VitePress OpenAPI',
   description: 'Generate VitePress API Documentation from OpenAPI specs.',
   themeConfig: {
-    nav: [],
+    nav: [
+      {
+        component: 'OALocaleSelect',
+      },
+    ],
 
     sidebar: [
       {
@@ -91,6 +95,10 @@ export default defineConfigWithTheme({
                 text: 'Operation tags slot',
                 link: '/customizations/operation-tags-slot',
               },
+              {
+                text: 'i18n',
+                link: '/customizations/i18n',
+              },
             ],
           },
         ],
diff --git a/docs/composables/useTheme.md b/docs/composables/useTheme.md
index f749454..8d44d2e 100644
--- a/docs/composables/useTheme.md
+++ b/docs/composables/useTheme.md
@@ -82,6 +82,11 @@ export default {
                         'operation.badgePrefix.operationId': 'ID de operación',
                     },
                 },
+                availableLocales: [
+                    { code: 'en', label: 'English' },
+                    { code: 'es', label: 'Español' },
+                    { code: 'pt-BR', label: 'Português (Brasil)' },
+                ],
             },
             // Set spec configuration.
             spec: {
diff --git a/docs/customizations/i18n.md b/docs/customizations/i18n.md
new file mode 100644
index 0000000..0df941b
--- /dev/null
+++ b/docs/customizations/i18n.md
@@ -0,0 +1,78 @@
+---
+aside: false
+outline: false
+---
+
+# Internationalization (i18n)
+
+You can use the `useTheme` composable to configure internationalization in your `.vitepress/theme/index.js` file, or in any `.md` page/file. All values are optional.
+
+```vue
+<script setup lang="ts">
+import { useTheme, locales } from 'vitepress-openapi'
+
+useTheme({
+  i18n: {
+    locale: 'en', // en | es | pt-BR | string
+    fallbackLocale: 'en', // en | es | pt-BR | string
+    messages: {
+      en: {
+        ...locales.en,
+        'custom i18n key': 'Custom i18n value',
+      },
+      es: {
+        ...locales.es,
+        'custom i18n key': 'Valor personalizado i18n',
+      },
+      'pt-BR': {
+        ...locales['pt-BR'],
+        'custom i18n key': 'Valor personalizado i18n',
+    },
+    availableLocales: [
+      { code: 'en', label: 'English' },
+      { code: 'es', label: 'Español' },
+      { code: 'pt-BR', label: 'Português (Brasil)' },
+    ],
+  },
+})
+</script>
+
+<!-- OASpec component or OAOperation component -->
+```
+
+If you're using the built-in VitePress i18n features, you can configure the locale as follows:
+
+```vue
+<script setup lang="ts">
+import { useTheme } from 'vitepress-openapi'
+import { useData } from 'vitepress'
+
+const { lang } = useData()
+
+useTheme({
+  i18n: {
+    locale: lang.value,
+  },
+})
+</script>
+
+<!-- OASpec component or OAOperation component -->
+```
+
+You can see a [Live Example](https://vitepress-openapi-i18n.vercel.app/) and the [Source Code](https://github.com/enzonotario/vitepress-openapi-i18n) for more details.
+
+## `OALocaleSelect` component
+
+You can use the `OALocaleSelect` component to render a select with the available locales. Keep in mind that this will change only `vitepress-openapi` components, not the entire VitePress site. For example, you can add it to Navbar in your `.vitepress/config.js`:
+
+```js
+export default defineConfigWithTheme({
+    themeConfig: {
+        nav: [
+            {
+                component: 'OALocaleSelect',
+            },
+        ],
+    },
+})
+```
diff --git a/docs/public/openapi-response-statuses.json b/docs/public/openapi-response-statuses.json
index e94938f..b43754a 100644
--- a/docs/public/openapi-response-statuses.json
+++ b/docs/public/openapi-response-statuses.json
@@ -25,13 +25,8 @@
             "content": {
               "application/json": {
                 "schema": {
-                  "type": "object",
-                  "properties": {
-                    "status": {
-                      "type": "string",
-                      "example": "200 OK"
-                    }
-                  }
+                  "type": "string",
+                  "example": "200 OK"
                 }
               }
             }
@@ -50,13 +45,8 @@
             "content": {
               "application/json": {
                 "schema": {
-                  "type": "object",
-                  "properties": {
-                    "status": {
-                      "type": "string",
-                      "example": "201 Created"
-                    }
-                  }
+                  "type": "string",
+                  "example": "201 Created"
                 }
               }
             }
@@ -75,13 +65,8 @@
             "content": {
               "application/json": {
                 "schema": {
-                  "type": "object",
-                  "properties": {
-                    "status": {
-                      "type": "string",
-                      "example": "400 Bad Request"
-                    }
-                  }
+                  "type": "string",
+                  "example": "400 Bad Request"
                 }
               }
             }
@@ -100,13 +85,8 @@
             "content": {
               "application/json": {
                 "schema": {
-                  "type": "object",
-                  "properties": {
-                    "status": {
-                      "type": "string",
-                      "example": "401 Unauthorized"
-                    }
-                  }
+                  "type": "string",
+                  "example": "401 Unauthorized"
                 }
               }
             }
@@ -125,13 +105,8 @@
             "content": {
               "application/json": {
                 "schema": {
-                  "type": "object",
-                  "properties": {
-                    "status": {
-                      "type": "string",
-                      "example": "403 Forbidden"
-                    }
-                  }
+                  "type": "string",
+                  "example": "403 Forbidden"
                 }
               }
             }
@@ -150,13 +125,8 @@
             "content": {
               "application/json": {
                 "schema": {
-                  "type": "object",
-                  "properties": {
-                    "status": {
-                      "type": "string",
-                      "example": "404 Not Found"
-                    }
-                  }
+                  "type": "string",
+                  "example": "404 Not Found"
                 }
               }
             }
@@ -175,13 +145,8 @@
             "content": {
               "application/json": {
                 "schema": {
-                  "type": "object",
-                  "properties": {
-                    "status": {
-                      "type": "string",
-                      "example": "500 Internal Server Error"
-                    }
-                  }
+                  "type": "string",
+                  "example": "500 Internal Server Error"
                 }
               }
             }
diff --git a/package.json b/package.json
index decc50c..c5ffb41 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "vitepress-openapi",
   "type": "module",
-  "version": "0.0.3-alpha.52",
+  "version": "0.0.3-alpha.53",
   "packageManager": "pnpm@9.1.1",
   "homepage": "https://vitepress-openapi.vercel.app/",
   "repository": {
diff --git a/src/components/Common/OALocaleSelect.vue b/src/components/Common/OALocaleSelect.vue
new file mode 100644
index 0000000..7eaa7f9
--- /dev/null
+++ b/src/components/Common/OALocaleSelect.vue
@@ -0,0 +1,42 @@
+<script setup>
+import { computed } from 'vue'
+import { useTheme } from '../../composables/useTheme'
+import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger } from '../ui/select'
+
+const themeConfig = useTheme()
+
+const i18nConfig = themeConfig.getI18nConfig()
+
+const defaultLocale = computed(() => i18nConfig.locale?.value ?? 'en')
+
+function onLocaleChange(locale) {
+  themeConfig.setI18nConfig({
+    locale,
+  })
+}
+</script>
+
+<template>
+  <div class="self-center">
+    <Select
+      :default-value="defaultLocale"
+      :model-value="i18nConfig.locale"
+      @update:model-value="onLocaleChange"
+    >
+      <SelectTrigger>
+        <span class="vpi-languages option-icon" />
+      </SelectTrigger>
+      <SelectContent>
+        <SelectGroup>
+          <SelectItem
+            v-for="locale in i18nConfig.availableLocales"
+            :key="locale.code"
+            :value="locale.code"
+          >
+            {{ locale.label }}
+          </SelectItem>
+        </SelectGroup>
+      </SelectContent>
+    </Select>
+  </div>
+</template>
diff --git a/src/components/index.ts b/src/components/index.ts
index 18c7b0e..4ebc032 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -21,6 +21,7 @@ import OASchemaTabs from './Schema/OASchemaTabs.vue'
 import OASecurity from './Security/OASecurity.vue'
 import OAInfo from './Common/OAInfo.vue'
 import OAServers from './Common/OAServers.vue'
+import OALocaleSelect from './Common/OALocaleSelect.vue'
 
 export {
   OASpec,
@@ -46,4 +47,5 @@ export {
   OASecurity,
   OAInfo,
   OAServers,
+  OALocaleSelect,
 }
diff --git a/src/components/ui/select/SelectContent.vue b/src/components/ui/select/SelectContent.vue
index 753b713..ed20a4a 100644
--- a/src/components/ui/select/SelectContent.vue
+++ b/src/components/ui/select/SelectContent.vue
@@ -19,6 +19,7 @@ const props = withDefaults(
   defineProps<SelectContentProps & { class?: HTMLAttributes['class'] }>(),
   {
     position: 'popper',
+    bodyLock: false,
   },
 )
 const emits = defineEmits<SelectContentEmits>()
diff --git a/src/composables/useSidebar.ts b/src/composables/useSidebar.ts
index 2374bfa..92face9 100644
--- a/src/composables/useSidebar.ts
+++ b/src/composables/useSidebar.ts
@@ -52,7 +52,7 @@ export function useSidebar({
   linkPrefix = DEFAULT_CONFIG.linkPrefix,
   tagLinkPrefix = DEFAULT_CONFIG.tagLinkPrefix,
   defaultTag = DEFAULT_CONFIG.defaultTag,
-  methodAliases,
+  methodAliases = {},
 }: SidebarConfig = {}) {
   useTheme({
     spec: {
diff --git a/src/composables/useTheme.ts b/src/composables/useTheme.ts
index 641f64b..964ea44 100644
--- a/src/composables/useTheme.ts
+++ b/src/composables/useTheme.ts
@@ -73,10 +73,16 @@ export type Languages = 'es' | 'en' | 'pt-BR' | string
 
 export type Messages = Record<Languages, Record<string, string>>
 
+export interface AvailableLocale {
+  code: string
+  label: string
+}
+
 export interface I18nConfig {
   locale: Ref<Languages>
   fallbackLocale: Ref<Languages>
   messages: Messages
+  availableLocales?: AvailableLocale[]
 }
 
 export interface SpecConfig {
@@ -259,6 +265,20 @@ const themeConfig: UseThemeConfig = {
     locale: ref<Languages>('en'),
     fallbackLocale: ref<Languages>('en'),
     messages: locales,
+    availableLocales: [
+      {
+        code: 'en',
+        label: 'English',
+      },
+      {
+        code: 'es',
+        label: 'Español',
+      },
+      {
+        code: 'pt-BR',
+        label: 'Português (Brasil)',
+      },
+    ],
   },
   spec: {
     groupByTags: ref(true),
@@ -598,6 +618,11 @@ export function useTheme(initialConfig: Partial<UseThemeConfigUnref> = {}) {
       // @ts-expect-error: This is a valid expression.
       themeConfig.i18n.messages = config.messages
     }
+
+    if (config.availableLocales) {
+      // @ts-expect-error: This is a valid expression.
+      themeConfig.i18n.availableLocales = config.availableLocales
+    }
   }
 
   function getSpecConfig() {
diff --git a/src/index.ts b/src/index.ts
index 3cfd37b..520e056 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -2,6 +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 { watch } from 'vue'
 import { DEFAULT_OPERATION_SLOTS, useTheme } from './composables/useTheme'
 import { useShiki } from './composables/useShiki'
 import * as components from './components'
@@ -38,7 +39,7 @@ interface VPTheme {
 }
 
 export const theme = {
-  enhanceApp({ app, openapi }) {
+  enhanceApp({ app, openapi = null }) {
     if (openapi) {
       app.provide('openapi', openapi)
     }
@@ -53,6 +54,18 @@ export const theme = {
     })
     app.use(i18n)
 
+    watch(
+      () => themeConfig.getLocale(),
+      () => {
+        if (i18n.global.locale.value !== themeConfig.getLocale()) {
+          i18n.global.locale.value = themeConfig.getLocale()
+        }
+      },
+      {
+        immediate: true,
+      },
+    )
+
     for (const key in components) {
       // @ts-expect-error: no index signature
       app.component(key, components[key])


Test patch:
diff --git a/test/composables/useTheme.test.ts b/test/composables/useTheme.test.ts
index f7df62a..a0fd82a 100644
--- a/test/composables/useTheme.test.ts
+++ b/test/composables/useTheme.test.ts
@@ -1,5 +1,4 @@
 import { beforeEach, describe, expect, it } from 'vitest'
-import { ref } from 'vue'
 import { useTheme } from '../../src/composables/useTheme'
 
 describe('composition API', () => {
@@ -84,16 +83,14 @@ describe('composition API', () => {
     expect(theme.getSecurityDefaultScheme()).toBe('bearer')
     expect(theme.getSecuritySelectedScheme()).toBe('bearer')
     expect(theme.getI18nConfig().locale.value).toBe('es')
-    expect(theme.getSpecConfig()).toEqual({
-      groupByTags: ref(false),
-      collapsePaths: ref(true),
-      showPathsSummary: ref(false),
-      avoidCirculars: ref(false),
-      lazyRendering: ref(false),
-      defaultTag: 'Default',
-      defaultTagDescription: '',
-      wrapExamples: true,
-    })
+    expect(theme.getSpecConfig().groupByTags.value).toBe(false)
+    expect(theme.getSpecConfig().collapsePaths.value).toBe(true)
+    expect(theme.getSpecConfig().showPathsSummary.value).toBe(false)
+    expect(theme.getSpecConfig().avoidCirculars.value).toBe(false)
+    expect(theme.getSpecConfig().lazyRendering.value).toBe(false)
+    expect(theme.getSpecConfig().defaultTag).toBe('Default')
+    expect(theme.getSpecConfig().defaultTagDescription).toBe('')
+    expect(theme.getSpecConfig().wrapExamples).toBe(true)
     expect(theme.getWrapExamples()).toBe(true)
   })
 
@@ -319,6 +316,16 @@ describe('useTheme', () => {
       locale: expect.any(Object),
       fallbackLocale: expect.any(Object),
       messages: expect.any(Object),
+      availableLocales: expect.arrayContaining([
+        {
+          code: 'en',
+          label: 'English',
+        },
+        {
+          code: 'es',
+          label: 'Español',
+        },
+      ]),
     })
   })
 
@@ -330,16 +337,14 @@ describe('useTheme', () => {
 
   it('returns the spec config', () => {
     const result = themeConfig.getSpecConfig()
-    expect(result).toEqual({
-      groupByTags: ref(true),
-      collapsePaths: ref(false),
-      showPathsSummary: ref(true),
-      avoidCirculars: ref(false),
-      lazyRendering: ref(false),
-      defaultTag: 'Default',
-      defaultTagDescription: '',
-      wrapExamples: true,
-    })
+    expect(result.groupByTags.value).toBe(true)
+    expect(result.collapsePaths.value).toBe(false)
+    expect(result.showPathsSummary.value).toBe(true)
+    expect(result.avoidCirculars.value).toBe(false)
+    expect(result.lazyRendering.value).toBe(false)
+    expect(result.defaultTag).toBe('Default')
+    expect(result.defaultTagDescription).toBe('')
+    expect(result.wrapExamples).toBe(true)
   })
 
   it('sets spec config', () => {