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/'))