From 36af62376fc9983474310e1a1c68e3d57543e9e7 Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Tue, 17 Mar 2026 17:26:22 +0000 Subject: [PATCH] feat(layer): add docs versionning --- layer/app/app.vue | 11 +- layer/app/components/VersionSelect.vue | 56 + layer/app/composables/useCollectionName.ts | 17 + layer/app/composables/useVersion.ts | 105 + layer/app/error.vue | 10 +- layer/app/layouts/docs.vue | 13 + layer/app/pages/[[lang]]/[...slug].vue | 5 +- layer/app/templates/landing.vue | 5 +- layer/app/utils/navigation.ts | 24 +- layer/content.config.ts | 99 +- layer/modules/config.ts | 64 +- layer/modules/routing.ts | 40 +- layer/server/mcp/tools/get-page.ts | 7 +- layer/server/mcp/tools/list-pages.ts | 11 +- layer/server/routes/sitemap.xml.ts | 5 +- layer/server/utils/content.ts | 90 +- layer/utils/pages.ts | 14 + package.json | 1 + playground-versioned/content/index.md | 36 + .../v3/docs/1.getting-started/.navigation.yml | 2 + .../docs/1.getting-started/1.introduction.md | 18 + .../docs/1.getting-started/2.installation.md | 17 + .../v4/docs/1.getting-started/.navigation.yml | 2 + .../docs/1.getting-started/1.introduction.md | 18 + .../docs/1.getting-started/2.installation.md | 17 + playground-versioned/nuxt.config.ts | 13 + playground-versioned/package-lock.json | 21809 ++++++++++++++++ playground-versioned/package.json | 13 + playground-versioned/public/favicon.ico | Bin 0 -> 15406 bytes playground-versioned/tsconfig.json | 17 + 30 files changed, 22493 insertions(+), 46 deletions(-) create mode 100644 layer/app/components/VersionSelect.vue create mode 100644 layer/app/composables/useCollectionName.ts create mode 100644 layer/app/composables/useVersion.ts create mode 100644 playground-versioned/content/index.md create mode 100644 playground-versioned/content/v3/docs/1.getting-started/.navigation.yml create mode 100644 playground-versioned/content/v3/docs/1.getting-started/1.introduction.md create mode 100644 playground-versioned/content/v3/docs/1.getting-started/2.installation.md create mode 100644 playground-versioned/content/v4/docs/1.getting-started/.navigation.yml create mode 100644 playground-versioned/content/v4/docs/1.getting-started/1.introduction.md create mode 100644 playground-versioned/content/v4/docs/1.getting-started/2.installation.md create mode 100644 playground-versioned/nuxt.config.ts create mode 100644 playground-versioned/package-lock.json create mode 100644 playground-versioned/package.json create mode 100644 playground-versioned/public/favicon.ico create mode 100644 playground-versioned/tsconfig.json diff --git a/layer/app/app.vue b/layer/app/app.vue index e1fd8077c..ab4b46a85 100644 --- a/layer/app/app.vue +++ b/layer/app/app.vue @@ -3,16 +3,19 @@ import type { ContentNavigationItem, PageCollections } from '@nuxt/content' import * as nuxtUiLocales from '@nuxt/ui/locale' import { transformNavigation } from './utils/navigation' import { useSubNavigation } from './composables/useSubNavigation' +import { useVersion } from './composables/useVersion' +import { useCollectionName } from './composables/useCollectionName' const { seo } = useAppConfig() const site = useSiteConfig() const { locale, locales, isEnabled, switchLocalePath } = useDocusI18n() const { isEnabled: isAssistantEnabled, panelWidth: assistantPanelWidth, shouldPushContent } = useAssistant() +const { version, isVersioned } = useVersion() const nuxtUiLocale = computed(() => nuxtUiLocales[locale.value as keyof typeof nuxtUiLocales] || nuxtUiLocales.en) const lang = computed(() => nuxtUiLocale.value.code) const dir = computed(() => nuxtUiLocale.value.dir) -const collectionName = computed(() => isEnabled.value ? `docs_${locale.value}` : 'docs') +const collectionName = useCollectionName('docs') useHead({ meta: [ @@ -47,12 +50,12 @@ if (isEnabled.value) { } const { data: navigation } = await useAsyncData(() => `navigation_${collectionName.value}`, () => queryCollectionNavigation(collectionName.value as keyof PageCollections), { - transform: (data: ContentNavigationItem[]) => transformNavigation(data, isEnabled.value, locale.value), - watch: [locale], + transform: (data: ContentNavigationItem[]) => transformNavigation(data, isEnabled.value, locale.value, isVersioned.value, version.value), + watch: [locale, version], }) const { data: files } = useLazyAsyncData(`search_${collectionName.value}`, () => queryCollectionSearchSections(collectionName.value as keyof PageCollections), { server: false, - watch: [locale], + watch: [locale, version], }) provide('navigation', navigation) diff --git a/layer/app/components/VersionSelect.vue b/layer/app/components/VersionSelect.vue new file mode 100644 index 000000000..9b9a533e0 --- /dev/null +++ b/layer/app/components/VersionSelect.vue @@ -0,0 +1,56 @@ + + + diff --git a/layer/app/composables/useCollectionName.ts b/layer/app/composables/useCollectionName.ts new file mode 100644 index 000000000..61b484c23 --- /dev/null +++ b/layer/app/composables/useCollectionName.ts @@ -0,0 +1,17 @@ +import { computed } from 'vue' + +export const useCollectionName = (type: 'docs' | 'landing' = 'docs') => { + const { version, isVersioned } = useVersion() + const { locale, isEnabled: isI18n } = useDocusI18n() + + return computed(() => { + let name: string = type + if (type === 'docs' && isVersioned.value && version.value) { + name += `_${version.value}` + } + if (isI18n.value) { + name += `_${locale.value}` + } + return name + }) +} diff --git a/layer/app/composables/useVersion.ts b/layer/app/composables/useVersion.ts new file mode 100644 index 000000000..de2bc0498 --- /dev/null +++ b/layer/app/composables/useVersion.ts @@ -0,0 +1,105 @@ +import { useRuntimeConfig, useCookie, useRoute, navigateTo } from '#imports' +import { ref, computed } from 'vue' + +export interface VersionItem { + label: string + value: string + tag?: string +} + +export interface VersionsRuntimeConfig { + strategy: 'prefix' | 'state' + default: string + items: VersionItem[] +} + +export const useVersion = () => { + const config = useRuntimeConfig().public + const versionsConfig = (config.docus as { versions?: VersionsRuntimeConfig } | undefined)?.versions + const isVersioned = ref(!!versionsConfig?.items?.length) + + if (!isVersioned.value || !versionsConfig) { + return { + isVersioned, + version: ref(''), + versions: [] as VersionItem[], + switchVersion: (_v: string) => {}, + versionStrategy: 'prefix' as const, + } + } + + const items = versionsConfig.items + const strategy = versionsConfig.strategy || 'prefix' + const defaultVersion = versionsConfig.default || items[0]!.value + + const resolveVersionFromRoute = (): string => { + if (strategy !== 'prefix') return defaultVersion + const route = useRoute() + const segments = route.path.split('/').filter(Boolean) + + const { isEnabled: isI18n } = useDocusI18n() + const startIdx = isI18n.value ? 1 : 0 + + const candidate = segments[startIdx] + if (candidate && items.some(v => v.value === candidate)) { + return candidate + } + return defaultVersion + } + + const versionCookie = strategy === 'state' + ? useCookie('docus-version', { default: () => defaultVersion }) + : null + + const version = computed({ + get: () => { + if (strategy === 'state') { + return versionCookie!.value || defaultVersion + } + return resolveVersionFromRoute() + }, + set: (v: string) => { + if (strategy === 'state' && versionCookie) { + versionCookie.value = v + } + }, + }) + + const switchVersion = (targetVersion: string) => { + if (!items.some(v => v.value === targetVersion)) return + + if (strategy === 'state') { + version.value = targetVersion + reloadNuxtApp() + return + } + + const route = useRoute() + const currentVersion = version.value + const currentPath = route.path + + let newPath: string + if (currentVersion && currentPath.includes(`/${currentVersion}`)) { + newPath = currentPath.replace(`/${currentVersion}`, `/${targetVersion}`) + } + else { + const { isEnabled: isI18n, locale } = useDocusI18n() + if (isI18n.value) { + newPath = currentPath.replace(`/${locale.value}`, `/${locale.value}/${targetVersion}`) + } + else { + newPath = `/${targetVersion}${currentPath}` + } + } + + navigateTo(newPath) + } + + return { + isVersioned, + version, + versions: items, + switchVersion, + versionStrategy: strategy, + } +} diff --git a/layer/app/error.vue b/layer/app/error.vue index 6a330707a..bba2ea9d1 100644 --- a/layer/app/error.vue +++ b/layer/app/error.vue @@ -3,12 +3,16 @@ import type { NuxtError } from '#app' import type { ContentNavigationItem, PageCollections } from '@nuxt/content' import * as nuxtUiLocales from '@nuxt/ui/locale' import { transformNavigation } from './utils/navigation' +import { useDocusColorMode } from './composables/useDocusColorMode' +import { useVersion } from './composables/useVersion' +import { useCollectionName } from './composables/useCollectionName' const props = defineProps<{ error: NuxtError }>() const { locale, locales, isEnabled, t, switchLocalePath } = useDocusI18n() +const { version, isVersioned } = useVersion() const nuxtUiLocale = computed(() => nuxtUiLocales[locale.value as keyof typeof nuxtUiLocales] || nuxtUiLocales.en) const lang = computed(() => nuxtUiLocale.value.code) @@ -45,11 +49,11 @@ if (isEnabled.value) { }) } -const collectionName = computed(() => isEnabled.value ? `docs_${locale.value}` : 'docs') +const collectionName = useCollectionName('docs') const { data: navigation } = await useAsyncData(`navigation_${collectionName.value}`, () => queryCollectionNavigation(collectionName.value as keyof PageCollections), { - transform: (data: ContentNavigationItem[]) => transformNavigation(data, isEnabled.value, locale.value), - watch: [locale], + transform: (data: ContentNavigationItem[]) => transformNavigation(data, isEnabled.value, locale.value, isVersioned.value, version.value), + watch: [locale, version], }) const { data: files } = useLazyAsyncData(`search_${collectionName.value}`, () => queryCollectionSearchSections(collectionName.value as keyof PageCollections), { server: false, diff --git a/layer/app/layouts/docs.vue b/layer/app/layouts/docs.vue index bb69a7c8a..888daeda8 100644 --- a/layer/app/layouts/docs.vue +++ b/layer/app/layouts/docs.vue @@ -1,9 +1,22 @@ + +