diff --git a/app/components/content/VersionBadge.vue b/app/components/content/VersionBadge.vue
new file mode 100644
index 000000000..65ad69b90
--- /dev/null
+++ b/app/components/content/VersionBadge.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
diff --git a/app/composables/useBlog.ts b/app/composables/useBlog.ts
index 664bbdf7a..64307cbbf 100644
--- a/app/composables/useBlog.ts
+++ b/app/composables/useBlog.ts
@@ -1,5 +1,21 @@
import type { BlogArticle } from '~/types'
+/**
+ * Paths of the release announcements, e.g. `/blog/v4-5`. Kept to paths only so version badges
+ * can check whether a release has a post to link to without pulling the whole blog.
+ */
+export const useReleaseArticlePaths = () => {
+ const { data: paths } = useAsyncData('release-article-paths', () => {
+ return queryCollection('blog')
+ .where('category', '=', 'Release')
+ .select('path')
+ .all()
+ .then(articles => articles.map(article => article.path))
+ }, { default: () => [] })
+
+ return { paths }
+}
+
export const useBlog = () => {
const { data: articles, refresh } = useAsyncData('blog', async () => {
return queryCollection('blog')
diff --git a/app/composables/useDocsVersion.ts b/app/composables/useDocsVersion.ts
index 7166f8380..fd862e85e 100644
--- a/app/composables/useDocsVersion.ts
+++ b/app/composables/useDocsVersion.ts
@@ -1,3 +1,4 @@
+import { coerce } from 'verkit'
import type { BadgeProps } from '@nuxt/ui'
interface Version {
@@ -51,8 +52,8 @@ const tagMap: Record = {
}
export const useDocsTags = () => {
- const { data: tags } = useAsyncData('versions', async () => {
- const { 'dist-tags': distTags } = await $fetch<{ 'dist-tags': Record }>('https://registry.npmjs.org/nuxt')
+ const { data: tags } = useAsyncData('versions', async (_nuxtApp, { signal }) => {
+ const { 'dist-tags': distTags } = await $fetch<{ 'dist-tags': Record }>('https://registry.npmjs.org/nuxt', { signal })
return Object.fromEntries(
Object.entries(tagMap).map(([shortTag]: [keyof typeof tagMap, string]) => {
// TODO: remove nightly fallback when Nuxt 5 is released
@@ -65,6 +66,22 @@ export const useDocsTags = () => {
return { tags }
}
+/** Latest release of the docs version being read, e.g. `4.5.2` */
+export const useDocsLatestVersion = () => {
+ const { version } = useDocsVersion()
+ const { tags } = useDocsTags()
+
+ const latest = computed(() => {
+ const shortTag = version.value?.shortTag
+ if (!shortTag) return undefined
+
+ // Falls back to the branch major (`v4` -> `4.0.0`) while the dist-tags are loading
+ return coerce(tags.value[shortTag] ?? '') ?? coerce(shortTag.slice(1)) ?? undefined
+ })
+
+ return { latest }
+}
+
export const useDocsVersion = () => {
const route = useRoute()
const { track } = useAnalytics()
diff --git a/app/composables/useVersionBadge.ts b/app/composables/useVersionBadge.ts
new file mode 100644
index 000000000..6e0b47c14
--- /dev/null
+++ b/app/composables/useVersionBadge.ts
@@ -0,0 +1,49 @@
+import type { MaybeRefOrGetter } from 'vue'
+
+export interface UseVersionBadgeOptions {
+ /** How far behind the latest release the requirement may be. Defaults to the whole current major. */
+ tolerance?: MaybeRefOrGetter
+}
+
+/**
+ * Decides whether a minimum Nuxt version is worth surfacing as a badge for the docs
+ * version currently being read — a requirement older than `tolerance` is not news anymore.
+ *
+ * `version` takes a version number or one of the {@link VERSION_KEYWORDS}, e.g. `unreleased`.
+ */
+export const useVersionBadge = (version: MaybeRefOrGetter, options: UseVersionBadgeOptions = {}) => {
+ const { version: docsVersion } = useDocsVersion()
+ const { latest } = useDocsLatestVersion()
+
+ const labels = computed(() => versionBadgeLabels(toValue(version), docsVersion.value?.shortTag))
+ const label = computed(() => labels.value?.label)
+ /** For tight spots like the sidebar — `soon` instead of `nightly v4` */
+ const shortLabel = computed(() => labels.value?.shortLabel)
+ const ariaLabel = computed(() => labels.value?.ariaLabel)
+
+ const show = computed(() => satisfiesVersionTolerance(toValue(version), latest.value, toValue(options.tolerance) ?? { major: 0 }))
+
+ const { paths: releasePaths } = useReleaseArticlePaths()
+
+ /**
+ * Where the badge points: the nightly channel guide for a keyword, the release announcement
+ * for a version number. `undefined` while a release has no post yet, so the badge stays plain
+ * text instead of linking into a 404.
+ */
+ const to = computed(() => {
+ const value = toValue(version)
+ if (versionKeyword(value)) return nightlyChannelPath(docsVersion.value?.path)
+
+ const path = versionBlogPath(value)
+ return path && releasePaths.value.includes(path) ? path : undefined
+ })
+
+ return {
+ label,
+ shortLabel,
+ ariaLabel,
+ to,
+ show,
+ latest
+ }
+}
diff --git a/app/pages/docs/[...slug].vue b/app/pages/docs/[...slug].vue
index 0bfa630ec..7967b23a1 100644
--- a/app/pages/docs/[...slug].vue
+++ b/app/pages/docs/[...slug].vue
@@ -18,6 +18,7 @@ const onThisPageDrawerOpen = ref(false)
const route = useRoute()
const nuxtApp = useNuxtApp()
const { version } = useDocsVersion()
+const { latest } = useDocsLatestVersion()
const { headerLinks } = useHeaderLinks()
const { isAgentDocked } = useNuxtAgent()
const path = computed(() => route.path.replace(/\/$/, ''))
@@ -30,6 +31,34 @@ const navClass = (item: ContentNavigationItem) => {
return ''
}
+// The navigation only flags what is genuinely new: the current and previous two minors
+const navVersionTolerance: VersionTolerance = { minor: 2 }
+
+const versionBadge = (item: ContentNavigationItem) => {
+ if (typeof item.minimalVersion !== 'string') return undefined
+
+ const minimal = item.minimalVersion.trim()
+ if (!satisfiesVersionTolerance(minimal, latest.value, navVersionTolerance)) return undefined
+
+ const labels = versionBadgeLabels(minimal, version.value?.shortTag)
+ if (!labels) return undefined
+
+ return {
+ // The sidebar has no room for `nightly v4`
+ 'label': labels.shortLabel,
+ 'size': 'sm' as const,
+ 'color': 'info' as const,
+ 'variant': 'subtle' as const,
+ 'aria-label': labels.ariaLabel
+ }
+}
+
+const withVersionBadge = (item: ContentNavigationItem): ContentNavigationItem => ({
+ ...item,
+ badge: versionBadge(item) ?? item.badge,
+ children: item.children?.map(withVersionBadge)
+})
+
// Get the aside navigation
const asideNavigation = computed(() => {
const path = [version.value.path, route.params.slug?.[version.value.path.split('/').length - 2]].filter(Boolean).join('/')
@@ -37,7 +66,7 @@ const asideNavigation = computed(() => {
const nav = navPageFromPath(path, navigation.value)?.children || []
return nav.map(item => ({
- ...item,
+ ...withVersionBadge(item),
class: navClass(item)
}))
})
@@ -219,16 +248,7 @@ const noRightAside = computed(() => route.path.includes('/examples/'))
{{ page.title }}
-
-
+
diff --git a/app/utils/version.ts b/app/utils/version.ts
new file mode 100644
index 000000000..f07394ac5
--- /dev/null
+++ b/app/utils/version.ts
@@ -0,0 +1,108 @@
+import { coerce, getMajor, getMinor, getPatch, isGreaterOrEqual } from 'verkit'
+
+/**
+ * Shortcuts for a requirement that has no version number yet, mapped to how they render:
+ * `label` where there is room, `short` in tight spots like the sidebar.
+ * They stand for something unreleased, so tolerance never filters them out.
+ */
+export const VERSION_KEYWORDS = {
+ unreleased: { label: 'nightly', short: 'Soon' }
+} as const
+
+export type VersionKeyword = keyof typeof VERSION_KEYWORDS
+
+/** The keyword `version` stands for, if it is one. */
+export function versionKeyword(version: string | undefined | null): VersionKeyword | undefined {
+ const value = version?.trim().toLowerCase()
+ return value && value in VERSION_KEYWORDS ? value as VersionKeyword : undefined
+}
+
+/**
+ * How a minimum version requirement reads as a badge — a version number as `v`,
+ * a keyword as its label qualified by the major it applies to (`unreleased` -> `nightly v4`,
+ * `soon` where space is tight). The aria label always spells out the long form.
+ * Returns `undefined` when there is nothing to show.
+ */
+export function versionBadgeLabels(version: string | undefined | null, tag?: string): { label: string, shortLabel: string, ariaLabel: string } | undefined {
+ const value = version?.trim()
+ if (!value) return undefined
+
+ const keyword = versionKeyword(value)
+ const label = keyword ? [VERSION_KEYWORDS[keyword].label, tag].filter(Boolean).join(' ') : `v${value}`
+ const shortLabel = keyword ? VERSION_KEYWORDS[keyword].short : label
+
+ return { label, shortLabel, ariaLabel: `Minimum Nuxt Version: ${label}` }
+}
+
+/**
+ * Where the release announcement for a version lives, e.g. `4.5.2` -> `/blog/v4-5`.
+ * A major release drops the minor (`4.0.0` -> `/blog/v4`), matching how the posts are named.
+ * The path is derived, not verified — check it against the blog collection before linking.
+ */
+export function versionBlogPath(version: string | undefined | null): string | undefined {
+ const target = coerce(version?.trim() ?? '')
+ if (!target) return undefined
+
+ const minor = getMinor(target)
+
+ return `/blog/v${getMajor(target)}${minor ? `-${minor}` : ''}`
+}
+
+/** The nightly release channel guide for a docs version, e.g. `/docs/4.x` -> how to install nightly. */
+export function nightlyChannelPath(docsPath: string | undefined): string | undefined {
+ if (!docsPath?.startsWith('/docs')) return undefined
+
+ return `${docsPath}/guide/going-further/nightly-release-channel`
+}
+
+/**
+ * How far *behind* the latest release a version may be and still be worth surfacing.
+ * Tolerance only ever looks backwards — versions newer than the latest release
+ * (unreleased minors, the next major) are always accepted.
+ *
+ * The finest given level sets the granularity, everything below it is zeroed:
+ * - `{ major: 0 }` keeps the whole current major
+ * - `{ major: 1 }` keeps the previous major too
+ * - `{ minor: 1 }` keeps the current and previous minor only
+ * - `{ patch: 2 }` keeps the current minor from two patches back
+ */
+export interface VersionTolerance {
+ major?: number
+ minor?: number
+ patch?: number
+}
+
+/**
+ * Lowest version still within `tolerance` of `latest`,
+ * e.g. `4.4.0` for latest `4.5.2` with `{ minor: 1 }`.
+ */
+export function versionThreshold(latest: string | undefined | null, tolerance: VersionTolerance = {}): string | undefined {
+ const release = coerce(latest ?? '')
+ if (!release) return undefined
+
+ const { major, minor, patch } = tolerance
+ const back = (value: number, offset = 0) => Math.max(0, value - offset)
+
+ return [
+ back(getMajor(release), major),
+ minor !== undefined ? back(getMinor(release), minor) : patch !== undefined ? getMinor(release) : 0,
+ patch !== undefined ? back(getPatch(release), patch) : 0
+ ].join('.')
+}
+
+/**
+ * Whether `version` is at or above the tolerated threshold, which means:
+ * - a keyword like `unreleased` -> always `true`, it describes a version that has no number yet
+ * - newer than `latest` -> always `true`
+ * - older than `latest` -> `true` while it is within `tolerance`
+ * - unparseable input -> `false`
+ */
+export function satisfiesVersionTolerance(version: string | undefined | null, latest: string | undefined | null, tolerance: VersionTolerance = {}): boolean {
+ if (versionKeyword(version)) return true
+
+ const target = coerce(version?.trim() ?? '')
+ const threshold = versionThreshold(latest, tolerance)
+ if (!target || !threshold) return false
+
+ return isGreaterOrEqual(target, threshold)
+}
diff --git a/package.json b/package.json
index e8bf8dd97..d97d9b874 100644
--- a/package.json
+++ b/package.json
@@ -80,6 +80,7 @@
"std-env": "^4.2.0",
"ufo": "^1.6.4",
"valibot": "^1.4.2",
+ "verkit": "^0.3.0",
"zod": "4.4.3"
},
"devDependencies": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cdf9dacd6..a1ba5c13a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -180,6 +180,9 @@ importers:
valibot:
specifier: ^1.4.2
version: 1.4.2(typescript@6.0.3)
+ verkit:
+ specifier: ^0.3.0
+ version: 0.3.0
zod:
specifier: 4.4.3
version: 4.4.3
@@ -9791,6 +9794,10 @@ packages:
resolution: {integrity: sha512-6M8R2xSKplkQ+0mAm07IMh548GLLPUnRQZJCCe/W4rfk/SXW2bs8ZJSWa5kjdIdsx3hLG5oLWROJ4+N5hpX08g==}
engines: {node: '>=18.12.0'}
+ verkit@0.3.0:
+ resolution: {integrity: sha512-Njrh4U8UODGajoZ44QS2C/BsoEM9DTI/aCqY5swsizb+/ap0FamvnCMcZAxrR5+aoC0ZqkawEfpC/N2SBc+xeA==}
+ engines: {node: '>=18.12.0'}
+
vfile-location@5.0.3:
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
@@ -21382,6 +21389,8 @@ snapshots:
verkit@0.2.0: {}
+ verkit@0.3.0: {}
+
vfile-location@5.0.3:
dependencies:
'@types/unist': 3.0.3
diff --git a/server/api/navigation.json.get.ts b/server/api/navigation.json.get.ts
index 68bd3791b..0a322aca2 100644
--- a/server/api/navigation.json.get.ts
+++ b/server/api/navigation.json.get.ts
@@ -8,9 +8,9 @@ export default defineEventHandler(async (event) => {
// `null` entry. Every consumer iterates these items reading `item.path`, so a
// single null took down SSR for the whole docs section.
return Promise.all([
- queryCollectionNavigation(event, 'docsv3', ['titleTemplate']).then(data => data[0]?.children ?? []),
- queryCollectionNavigation(event, 'docsv4', ['titleTemplate']).then(data => data[0]?.children ?? []),
- queryCollectionNavigation(event, 'docsv5', ['titleTemplate']).then(data => data[0]?.children ?? []),
+ queryCollectionNavigation(event, 'docsv3', ['titleTemplate', 'minimalVersion']).then(data => data[0]?.children ?? []),
+ queryCollectionNavigation(event, 'docsv4', ['titleTemplate', 'minimalVersion']).then(data => data[0]?.children ?? []),
+ queryCollectionNavigation(event, 'docsv5', ['titleTemplate', 'minimalVersion']).then(data => data[0]?.children ?? []),
queryCollectionNavigation(event, 'blog')
]).then(data => data.flat().filter(Boolean))
})
diff --git a/test/nuxt/index.spec.ts b/test/nuxt/index.spec.ts
index a4240a1f0..99c930936 100644
--- a/test/nuxt/index.spec.ts
+++ b/test/nuxt/index.spec.ts
@@ -161,3 +161,85 @@ describe('utils/index', () => {
})
})
})
+
+describe('utils/version', () => {
+ describe('versionKeyword', () => {
+ it('should recognize keywords regardless of casing and padding', () => {
+ expect(versionKeyword('unreleased')).toBe('unreleased')
+ expect(versionKeyword(' Unreleased ')).toBe('unreleased')
+ })
+
+ it('should ignore version numbers and unknown words', () => {
+ expect(versionKeyword('4.2.0')).toBeUndefined()
+ expect(versionKeyword('nightly')).toBeUndefined()
+ expect(versionKeyword(undefined)).toBeUndefined()
+ })
+ })
+
+ describe('versionBadgeLabels', () => {
+ it('should prefix version numbers with v', () => {
+ expect(versionBadgeLabels('4.2')).toEqual({
+ label: 'v4.2',
+ shortLabel: 'v4.2',
+ ariaLabel: 'Minimum Nuxt Version: v4.2'
+ })
+ })
+
+ it('should render keywords as their label qualified with the docs tag', () => {
+ expect(versionBadgeLabels('unreleased', 'v4')).toEqual({
+ label: 'nightly v4',
+ shortLabel: 'Soon',
+ ariaLabel: 'Minimum Nuxt Version: nightly v4'
+ })
+ })
+
+ it('should render the bare keyword label without a docs tag', () => {
+ expect(versionBadgeLabels('unreleased')?.label).toBe('nightly')
+ })
+
+ it('should return undefined for empty input', () => {
+ expect(versionBadgeLabels(' ')).toBeUndefined()
+ expect(versionBadgeLabels(undefined)).toBeUndefined()
+ })
+ })
+
+ describe('versionBlogPath', () => {
+ it('should point at the minor release announcement', () => {
+ expect(versionBlogPath('4.5.2')).toBe('/blog/v4-5')
+ expect(versionBlogPath('3.18')).toBe('/blog/v3-18')
+ })
+
+ it('should drop the minor for a major release', () => {
+ expect(versionBlogPath('4.0.0')).toBe('/blog/v4')
+ expect(versionBlogPath('4')).toBe('/blog/v4')
+ })
+
+ it('should return undefined for keywords and unparseable input', () => {
+ expect(versionBlogPath('unreleased')).toBeUndefined()
+ expect(versionBlogPath(undefined)).toBeUndefined()
+ })
+ })
+
+ describe('nightlyChannelPath', () => {
+ it('should build the guide path for a docs version', () => {
+ expect(nightlyChannelPath('/docs/4.x')).toBe('/docs/4.x/guide/going-further/nightly-release-channel')
+ })
+
+ it('should ignore paths outside the docs', () => {
+ expect(nightlyChannelPath('https://v2.nuxt.com')).toBeUndefined()
+ expect(nightlyChannelPath(undefined)).toBeUndefined()
+ })
+ })
+
+ describe('satisfiesVersionTolerance', () => {
+ it('should always surface keywords', () => {
+ expect(satisfiesVersionTolerance('unreleased', '4.5.2', { minor: 2 })).toBe(true)
+ })
+
+ it('should keep the tolerated range for version numbers', () => {
+ expect(satisfiesVersionTolerance('4.4.0', '4.5.2', { minor: 2 })).toBe(true)
+ expect(satisfiesVersionTolerance('4.2.0', '4.5.2', { minor: 2 })).toBe(false)
+ expect(satisfiesVersionTolerance('nope', '4.5.2')).toBe(false)
+ })
+ })
+})